Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,7 @@
*/
@property (nonatomic) BOOL closed;

/** Set after -main if a stage failed (vs. completed or was cancelled). nil on success. */
@property (nonatomic, readonly, nullable) NSString *failureReason;

@end
55 changes: 39 additions & 16 deletions CppDependencies/PoissonRecon/Sources/src/MeshingOperation.mm
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ @implementation MeshingOperation {
NSString *_outputFilePath;
}

static unsigned long long FileSizeAtPath(NSString *path) {
NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:path error:NULL];
return attrs ? [attrs[NSFileSize] unsignedLongLongValue] : 0;
}

- (instancetype)initWithInputFilePath:(NSString *)inputPath
outputFilePath:(NSString *)outputPath
{
Expand All @@ -52,43 +57,61 @@ - (void)main
NSString *tempPoissonOutputPathString = [NSTemporaryDirectory() stringByAppendingFormat:@"/poisson-%@.ply", [[NSUUID UUID] UUIDString]];
const char *poissonOutputPath = [tempPoissonOutputPathString UTF8String];
const char *surfaceTrimmerOutputPath = [_outputFilePath UTF8String];


// Treat a non-existent or trivially small PLY as silent failure from the
// PoissonRecon/SurfaceTrimmer C++ layer. A well-formed PLY header alone is
// ~100 bytes; anything below this means the writer never produced geometry.
static const unsigned long long kMinValidPLYBytes = 256;

PoissonReconParameters poissonParams;
poissonParams.Depth = (int)remapAndClamp(_resolution, 1, 10, 4, 14);
poissonParams.SamplesPerNode = (int)remapAndClamp(_smoothness, 1, 10, 1, 15);

SurfaceTrimmerParameters surfaceTrimmerParams;
surfaceTrimmerParams.Trim = (int)remapAndClamp(_surfaceTrimmingAmount, 1, 10, 1, 10);

__weak MeshingOperation *weakSelf = self;
auto progressHandler = _progressHandler;

if (![weakSelf isCancelled]) {
PoissonReconExecute(inputPath, poissonOutputPath, _closed, poissonParams, [weakSelf, progressHandler](float progress) {
float adjustedProgress = remapAndClamp(progress, 0, 1, 0, kPoissonProgressFraction);

progressHandler(adjustedProgress);

BOOL shouldContinue = [weakSelf isCancelled];
return !shouldContinue;
BOOL cancelled = [weakSelf isCancelled];
return !cancelled;
});

if (![weakSelf isCancelled] && FileSizeAtPath(tempPoissonOutputPathString) < kMinValidPLYBytes) {
_failureReason = @"PoissonRecon produced no output (NULL solver result). The input point cloud is likely too sparse, has invalid normals, or is degenerate (all points coplanar).";
}
}
if (![weakSelf isCancelled]) {

if (![weakSelf isCancelled] && _failureReason == nil) {
if (_surfaceTrimmingAmount <= 0) {
// Trimming disabled, just move the file to the destination
[[NSFileManager defaultManager] moveItemAtPath:tempPoissonOutputPathString toPath:_outputFilePath error:NULL];
NSError *moveError = nil;
[[NSFileManager defaultManager] moveItemAtPath:tempPoissonOutputPathString toPath:_outputFilePath error:&moveError];
if (moveError) {
_failureReason = [NSString stringWithFormat:@"Failed to move Poisson output to destination: %@", moveError.localizedDescription];
}
} else {
SurfaceTrimmerExecute(poissonOutputPath, surfaceTrimmerOutputPath, surfaceTrimmerParams, [weakSelf, progressHandler](float progress) {
int trimResult = SurfaceTrimmerExecute(poissonOutputPath, surfaceTrimmerOutputPath, surfaceTrimmerParams, [weakSelf, progressHandler](float progress) {
float adjustedProgress = remapAndClamp(progress, 0, 1, kPoissonProgressFraction, 1);
progressHandler(adjustedProgress);

BOOL shouldContinue = [weakSelf isCancelled];
return !shouldContinue;
BOOL cancelled = [weakSelf isCancelled];
return !cancelled;
});

if (![weakSelf isCancelled]) {
if (trimResult != 0) {
_failureReason = @"SurfaceTrimmer failed: PLY header unreadable, missing density values, or Poisson output corrupt.";
} else if (FileSizeAtPath(_outputFilePath) < kMinValidPLYBytes) {
_failureReason = @"SurfaceTrimmer produced no geometry (empty mesh after trimming).";
}
}
}
}

[[NSFileManager defaultManager] removeItemAtPath:tempPoissonOutputPathString error:NULL];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ PBFAssimilatedFrameMetadata PBFModel::assimilate(ProcessedFrame& frame,
ICPConfiguration icpConfig,
SurfelFusionConfiguration surfelFusionConfiguration,
double currentTime,
const std::vector<ScreenSpaceLandmark>* screenSpaceLandmarks)
const std::vector<ScreenSpaceLandmark>* screenSpaceLandmarks,
const Eigen::Matrix4f* headPoseDelta,
float headPoseConfidence)
{
// Summary of algorithm:
// The first frame is defined to be identity for the world coordinates
Expand All @@ -178,7 +180,19 @@ PBFAssimilatedFrameMetadata PBFModel::assimilate(ProcessedFrame& frame,
// That resulting transform is multiplied into the world transform
// The surfel index map is drawn from the point of view of the incoming frame (inverse world transform)
// Points in the new frame are un-projected into 3D based on the world transform

//
// Head-pose-prior extension (Phase B/C of head-pose-aware scan rework):
// When a caller (e.g. ARFaceCameraManager) provides headPoseDelta with high
// confidence, we trust it and skip ICP entirely -- this lets us fuse frames
// when ICP would otherwise reject them due to head motion that ICP can't
// distinguish from camera motion. At lower confidence we still run ICP but
// could (future) seed it with the prior; for now low-conf falls through to
// pure ICP.

// Thresholds (tuned conservatively; high-conf bypass requires ARFaceAnchor
// .isTracked == true which Swift maps to confidence == 1.0f).
static const float kHighConfidenceThreshold = 0.9f;

// Get the current most recent metadata (haven't pushed this frame's metadata yet)
PBFAssimilatedFrameMetadata* previousFrameMeta = _nthMostRecentValidFrameMetadata(0);

Expand All @@ -192,44 +206,59 @@ PBFAssimilatedFrameMetadata PBFModel::assimilate(ProcessedFrame& frame,
const RawFrame& rawFrame = frame.rawFrame;
const size_t width = rawFrame.width;
const size_t height = rawFrame.height;


const bool useHighConfPriorBypass =
(headPoseDelta != nullptr) && (headPoseConfidence >= kHighConfidenceThreshold);

if (_surfels.size() > 0) {
ICPResult icpResult = _runICP(frame, surfelFusionConfiguration, icpConfig, pbfConfig);

Matrix4f extrinsicMatrixTmp = toMatrix4f(icpResult.sourceTransform) * _extrinsicMatrix;
// Store this whether or not we end up using it since we also store information about whether
// the frame was assimilated or not
frameMeta.viewMatrix = extrinsicMatrixTmp;
frameMeta.icpIterationCount = icpResult.iterationCount;
frameMeta.correspondenceError = icpResult.rmsCorrespondenceError;

if (!icpResult.succeeded) {
DEBUG_LOG("ICP rejected due to bad convergence after %d/%d iterations", icpResult.iterationCount, icpConfig.maxIterations);
frameMeta.icpUnusedIterationFraction = 0;
if (useHighConfPriorBypass) {
// High-confidence head-pose prior: skip ICP. headPoseDelta is the
// frame-to-frame camera-in-head delta supplied by the caller; apply
// it to the cumulative extrinsic and proceed straight to fusion.
Matrix4f extrinsicMatrixTmp = (*headPoseDelta) * _extrinsicMatrix;
frameMeta.viewMatrix = extrinsicMatrixTmp;
frameMeta.icpIterationCount = 0;
frameMeta.correspondenceError = 0.0f;
frameMeta.icpUnusedIterationFraction = 1.0f;
_extrinsicMatrix = extrinsicMatrixTmp;
} else {
frameMeta.icpUnusedIterationFraction = 1.0f - (float)icpResult.iterationCount / (float)icpConfig.maxIterations;

CameraVelocity cv = _cameraVelocity(previousFrameMeta, &frameMeta);

if (cv.angularVelocity.hasNaN() || cv.angularVelocity.norm() > pbfConfig.maxCameraAngularVelocity) {
DEBUG_LOG("Rejecting ICP due to bad fit with angular velocity %f", cv.angularVelocity.norm());
ICPResult icpResult = _runICP(frame, surfelFusionConfiguration, icpConfig, pbfConfig);

Matrix4f extrinsicMatrixTmp = toMatrix4f(icpResult.sourceTransform) * _extrinsicMatrix;
// Store this whether or not we end up using it since we also store information about whether
// the frame was assimilated or not
frameMeta.viewMatrix = extrinsicMatrixTmp;
frameMeta.icpIterationCount = icpResult.iterationCount;
frameMeta.correspondenceError = icpResult.rmsCorrespondenceError;

if (!icpResult.succeeded) {
DEBUG_LOG("ICP rejected due to bad convergence after %d/%d iterations", icpResult.iterationCount, icpConfig.maxIterations);
frameMeta.icpUnusedIterationFraction = 0;
} else {
frameMeta.icpUnusedIterationFraction = 1.0f - (float)icpResult.iterationCount / (float)icpConfig.maxIterations;

CameraVelocity cv = _cameraVelocity(previousFrameMeta, &frameMeta);

if (cv.angularVelocity.hasNaN() || cv.angularVelocity.norm() > pbfConfig.maxCameraAngularVelocity) {
DEBUG_LOG("Rejecting ICP due to bad fit with angular velocity %f", cv.angularVelocity.norm());
frameMeta.icpUnusedIterationFraction = 0;
}

else if (cv.velocity.hasNaN() || cv.velocity.norm() > pbfConfig.maxCameraVelocity) {
DEBUG_LOG("Rejecting ICP due to bad fit with linear velocity %f", cv.velocity.norm());
frameMeta.icpUnusedIterationFraction = 0;
}
}

else if (cv.velocity.hasNaN() || cv.velocity.norm() > pbfConfig.maxCameraVelocity) {
DEBUG_LOG("Rejecting ICP due to bad fit with linear velocity %f", cv.velocity.norm());
frameMeta.icpUnusedIterationFraction = 0;

if (frameMeta.icpUnusedIterationFraction > 0) {
_extrinsicMatrix = extrinsicMatrixTmp;
} else {
// It didn't converge in time, so bail out
DEBUG_LOG("ICP didn't converge with enough quality (%f) after %d/%d iterations. Ignoring frame.", frameMeta.icpUnusedIterationFraction, icpResult.iterationCount, icpConfig.maxIterations);
_assimilatedFrameMetadatas.push_back(frameMeta);
return frameMeta;
}
}

if (frameMeta.icpUnusedIterationFraction > 0) {
_extrinsicMatrix = extrinsicMatrixTmp;
} else {
// It didn't converge in time, so bail out
DEBUG_LOG("ICP didn't converge with enough quality (%f) after %d/%d iterations. Ignoring frame.", frameMeta.icpUnusedIterationFraction, icpResult.iterationCount, icpConfig.maxIterations);
_assimilatedFrameMetadatas.push_back(frameMeta);
return frameMeta;
}
}

if (_surfels.size() == 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ class PBFModel {
ICPConfiguration icpConfig,
SurfelFusionConfiguration surfelFusionConfiguration,
double currentTime,
const std::vector<ScreenSpaceLandmark>* screenSpaceLandmarks = NULL);
const std::vector<ScreenSpaceLandmark>* screenSpaceLandmarks = NULL,
const Eigen::Matrix4f* headPoseDelta = nullptr,
float headPoseConfidence = 0.0f);

PBFFinalStatistics finishAssimilating(SurfelFusionConfiguration surfelFusionConfiguration);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#import <standard_cyborg/math/Mat3x3.hpp>
#import <standard_cyborg/math/Mat3x4.hpp>
#import <standard_cyborg/math/Vec2.hpp>
#import <sys/utsname.h>
#import <vector>

#import "EigenHelpers.hpp"
Expand All @@ -18,6 +19,19 @@

using namespace standard_cyborg;

// iPhone 17 Pro and 17 Pro Max ship a 16:9 TrueDepth sensor that needs a
// different orientation matrix than the 4:3 sensor used on every prior
// iPhone. Apple identifies them as iPhone18,1 and iPhone18,2 respectively.
// Selecting by device model rather than sensor aspect ratio because the
// upstream's aspect-ratio heuristic mis-classified iPhone 15 Pro Max as
// widescreen and flipped its scan orientation.
static bool _isIPhone17ProFamily(void)
{
struct utsname info;
if (uname(&info) != 0) return false;
return strncmp(info.machine, "iPhone18,", 9) == 0;
}

sc3d::PerspectiveCamera PerspectiveCameraFromAVCameraCalibrationData(AVCameraCalibrationData *calibrationData, size_t pixelsWide, size_t pixelsHigh)
{
NSData *lensDistortionLookupTableData = calibrationData.lensDistortionLookupTable;
Expand Down Expand Up @@ -46,13 +60,9 @@
// coordinate system we otherwise prefer, so we post-multiply by an orientation matrix to
// produce world space with x to the right, y up, and z toward the user.
//
// On 4:3 TrueDepth sensors, the sensor's pixel-u axis points UP in portrait, so we swap
// X↔Y and negate Z. On 16:9 sensors (iPhone 17 Pro+), the pixel-u axis points RIGHT in
// portrait, so no swap is needed — just negate Y and Z.
CGSize refDims = calibrationData.intrinsicMatrixReferenceDimensions;
bool isWidescreenSensor = (refDims.width / refDims.height) > 1.5f;

math::Mat3x4 desiredOrientation = isWidescreenSensor
// iPhone 17 Pro family (16:9 sensor): pixel-u axis points RIGHT in portrait — just negate
// Y and Z. Every other iPhone (4:3 sensor): swap X↔Y and negate Z.
math::Mat3x4 desiredOrientation = _isIPhone17ProFamily()
? math::Mat3x4({
1, 0, 0, 0,
0, -1, 0, 0,
Expand Down
Loading