Align default realtime face-follow with automatic primary association and stabilization

这个提交包含在:
dayu 于 2026-10-21 18:42:09 +08:00 提交
1 父节点 764994a1c4
当前提交 e03e460a9b
4 个文件已更改
+391 -13

没有与搜索条件相匹配的文件

+6 -1
查看文件
@@ -10,6 +10,10 @@ NS_ASSUME_NONNULL_BEGIN
/// Vision observations in normalized, bottom-left image coordinates. Restored /// Vision observations in normalized, bottom-left image coordinates. Restored
/// observations retain their bounding box; render geometry is stored separately. /// observations retain their bounding box; render geometry is stored separately.
@property (nonatomic, copy, readonly) NSArray<VNFaceObservation *> *faces; @property (nonatomic, copy, readonly) NSArray<VNFaceObservation *> *faces;
/// Stable, normalized control geometry used for image and mask deformation.
/// Default realtime processing deforms one primary face while faces/masks may
/// cover all current detections. Explicit fast-multi mode deforms every face.
@property (nonatomic, copy, readonly) NSArray<NSDictionary *> *deformationGeometry;
@property (nonatomic, strong, readonly) CIImage *skinMask; @property (nonatomic, strong, readonly) CIImage *skinMask;
/// Continuous face support before eye/nose/lip detail-protection cutouts. /// Continuous face support before eye/nose/lip detail-protection cutouts.
@property (nonatomic, strong, readonly) CIImage *colorMask; @property (nonatomic, strong, readonly) CIImage *colorMask;
@@ -31,7 +35,8 @@ NS_ASSUME_NONNULL_BEGIN
error:(NSError * _Nullable * _Nullable)error; error:(NSError * _Nullable * _Nullable)error;
- (void)resetTracking; - (void)resetTracking;
/// Targets are zero-based (0, 1, 2). Data is a versioned property-list archive, /// Targets are zero-based (0, 1, 2). Data is a versioned property-list archive,
/// independent of Vision implementation classes. Only analysis writes frames. /// independent of Vision implementation classes. Only analysis writes these
/// persisted frames; realtime geometry has a separate bounded, in-memory cache.
- (nullable NSData *)trackingDataForTarget:(NSUInteger)target; - (nullable NSData *)trackingDataForTarget:(NSUInteger)target;
- (void)setTrackingData:(nullable NSData *)data forTarget:(NSUInteger)target; - (void)setTrackingData:(nullable NSData *)data forTarget:(NSUInteger)target;
@end @end
+194 -11
查看文件
@@ -9,6 +9,7 @@ static NSString * const PGFaceErrorDomain = @"media.dayu.PaintFCP.Face";
@interface PGFaceFrame () @interface PGFaceFrame ()
@property (nonatomic, copy, readwrite) NSArray<VNFaceObservation *> *faces; @property (nonatomic, copy, readwrite) NSArray<VNFaceObservation *> *faces;
@property (nonatomic, copy, readwrite) NSArray<NSDictionary *> *deformationGeometry;
@property (nonatomic, strong, readwrite) CIImage *skinMask; @property (nonatomic, strong, readwrite) CIImage *skinMask;
@property (nonatomic, strong, readwrite) CIImage *colorMask; @property (nonatomic, strong, readwrite) CIImage *colorMask;
@property (nonatomic, strong, readwrite) CIImage *teethMask; @property (nonatomic, strong, readwrite) CIImage *teethMask;
@@ -119,6 +120,151 @@ static CIImage *PGMaximum(CIImage *a, CIImage *b) {
return [a imageByApplyingFilter:@"CIMaximumCompositing" withInputParameters:@{kCIInputBackgroundImageKey:b}]; return [a imageByApplyingFilter:@"CIMaximumCompositing" withInputParameters:@{kCIInputBackgroundImageKey:b}];
} }
static CMTime PGRecordTime(NSDictionary *record) {
return CMTimeMakeWithEpoch([record[@"value"] longLongValue], [record[@"scale"] intValue], [record[@"epoch"] longLongValue]);
}
static double PGSeedDistance(NSDictionary *record, PGBeautySettings settings) {
return hypot([record[@"seedX"] doubleValue]-settings.selectionX, [record[@"seedY"] doubleValue]-settings.selectionY);
}
static NSArray<NSString *> *PGGeometryRegions(void) {
return @[@"leftEye",@"rightEye",@"leftBrow",@"rightBrow",@"nose",@"lips",@"mouth",@"contour"];
}
static CGPoint PGRemapPoint(NSArray *point, CGRect old, CGRect current) {
return CGPointMake(current.origin.x+([point[0] doubleValue]-old.origin.x)*current.size.width/old.size.width,
current.origin.y+([point[1] doubleValue]-old.origin.y)*current.size.height/old.size.height);
}
static NSDictionary *PGStableGeometry(NSDictionary *current, NSDictionary *previous, double dt) {
CGRect old=PGArrayRect(previous[@"box"]),box=PGArrayRect(current[@"box"]);
if(!PGValidRect(old) || !PGValidRect(box))return current;
// Independent stabilization: translate/scale old landmarks into the current
// detection box first, so normal head motion does not accumulate box lag.
double weight=.9*fmin(1,fmax(0,dt/.03));
CGPoint oldLE=PGCenter(PGRegionBounds(previous,@"leftEye",PGInFace(old,.16,.53,.25,.15)));
CGPoint oldRE=PGCenter(PGRegionBounds(previous,@"rightEye",PGInFace(old,.59,.53,.25,.15)));
CGPoint newLE=PGCenter(PGRegionBounds(current,@"leftEye",PGInFace(box,.16,.53,.25,.15)));
CGPoint newRE=PGCenter(PGRegionBounds(current,@"rightEye",PGInFace(box,.59,.53,.25,.15)));
double ox=oldRE.x-oldLE.x,oy=oldRE.y-oldLE.y,nx=newRE.x-newLE.x,ny=newRE.y-newLE.y;
double oldLength=hypot(ox,oy),newLength=hypot(nx,ny);
BOOL axisHold=oldLength>MAX(.003,.055*MIN(old.size.height,box.size.height)) &&
newLength>MAX(.003,.055*MIN(old.size.height,box.size.height)) &&
(ox*nx+oy*ny)/(oldLength*newLength)<-.18;
NSMutableDictionary *stable=[NSMutableDictionary dictionaryWithObject:current[@"box"] forKey:@"box"];
for(NSString *name in PGGeometryRegions()) {
NSArray *a=previous[name],*b=current[name];
if(!a.count || (!axisHold && a.count!=b.count)) {stable[name]=b;continue;}
NSMutableArray *points=[NSMutableArray arrayWithCapacity:a.count];
for(NSUInteger i=0;i<a.count;i++) {
CGPoint p=PGRemapPoint(a[i],old,box);
if(!axisHold) {p.x+=( [b[i][0] doubleValue]-p.x)*weight;p.y+=([b[i][1] doubleValue]-p.y)*weight;}
[points addObject:@[@(p.x),@(p.y)]];
}
stable[name]=[points copy];
}
return [stable copy];
}
// This live selector is deliberately independent of _targets. Its past-only
// .35s reference window is a documented project policy, not a recovered exact
// vendor formula. Continuous held outputs advance time, so this is not a hold
// timeout. Locks protect state, not global render-order equivalence: a cold seek
// cannot reconstruct frames the host has never supplied.
@interface PGRealtimeFaceTracker : NSObject {
NSLock *_mutex;
NSMutableArray<NSDictionary *> *_entries;
NSUInteger _estimatedBytes;
}
- (NSDictionary *)resultForGeometry:(NSArray<NSDictionary *> *)raw settings:(PGBeautySettings)settings time:(CMTime)time extent:(CGRect)extent;
- (void)reset;
- (NSDictionary *)cacheStatistics;
@end
@implementation PGRealtimeFaceTracker
- (instancetype)init {if((self=[super init])){_mutex=[NSLock new];_entries=[NSMutableArray new];}return self;}
- (void)reset {[_mutex lock];[_entries removeAllObjects];_estimatedBytes=0;[_mutex unlock];}
- (NSDictionary *)cacheStatistics {[_mutex lock];NSDictionary *stats=@{@"frames":@(_entries.count),@"estimatedBytes":@(_estimatedBytes)};[_mutex unlock];return stats;}
- (NSDictionary *)resultForGeometry:(NSArray<NSDictionary *> *)input settings:(PGBeautySettings)s time:(CMTime)time extent:(CGRect)extent {
NSMutableArray *valid=[NSMutableArray new];
for(id item in input) {NSDictionary *g=PGSanitizedGeometry(item);if(g && valid.count<16)[valid addObject:g];}
NSArray *raw=[valid copy];
NSUInteger mode=s.fastMultiFace?2:(s.singleFace?1:0);
NSString *key=PGTimeKey(time);
NSString *context=[NSString stringWithFormat:@"%lu/%u/%lld/%.17g/%.17g/%.17g/%.17g/%.9g",(unsigned long)mode,s.targetIndex,
(long long)time.epoch,extent.origin.x,extent.origin.y,extent.size.width,extent.size.height,s.faceJumpGuardRange];
[_mutex lock];
NSDictionary *head=nil;
for(NSDictionary *record in _entries) if([record[@"context"] isEqual:context] &&
(!head || CMTimeCompare(PGRecordTime(record),PGRecordTime(head))>=0)) head=record;
NSString *scope=@"automatic";
double seedX=PGClamp(s.selectionX,0,1),seedY=PGClamp(s.selectionY,0,1);
if(mode==1) {
// A real point change (> .01) starts a new identity scope. Older render
// snapshots may use their own past scope, but never replace a newer head.
NSDictionary *seedRecord=head;
if(seedRecord && CMTimeCompare(time,PGRecordTime(seedRecord))<0) {
seedRecord=nil;
for(NSDictionary *record in _entries) if([record[@"context"] isEqual:context] && PGSeedDistance(record,s)<=.01 &&
CMTimeCompare(PGRecordTime(record),time)<=0 && (!seedRecord || CMTimeCompare(PGRecordTime(record),PGRecordTime(seedRecord))>=0))seedRecord=record;
}
if(seedRecord && PGSeedDistance(seedRecord,s)<=.01) {
scope=seedRecord[@"scope"];seedX=[seedRecord[@"seedX"] doubleValue];seedY=[seedRecord[@"seedY"] doubleValue];
} else scope=[NSString stringWithFormat:@"%@/%.9g/%.9g",key?:@"invalid",seedX,seedY];
}
NSDictionary *reference=nil;
if(key) for(NSDictionary *record in _entries) {
if(![record[@"context"] isEqual:context] || ![record[@"scope"] isEqual:scope])continue;
if([record[@"key"] isEqual:key] && [record[@"raw"] isEqual:raw]) {[_mutex unlock];return record;}
CMTime stored=PGRecordTime(record);
// Same-time changed detections recompute from an earlier frame, never
// feed an already smoothed result into another pass at that same time.
if(CMTimeCompare(stored,time)>=0)continue;
double dt=CMTimeGetSeconds(CMTimeSubtract(time,stored));
if(dt<=.35 && (!reference || CMTimeCompare(stored,PGRecordTime(reference))>0))reference=record;
}
NSArray *prior=reference[@"output"];
NSDictionary *previous=prior.count?prior.firstObject:nil;
CGRect previousBox=PGArrayRect(previous[@"box"]);
NSInteger index=-1;double best=-DBL_MAX;
if(mode!=2) for(NSUInteger i=0;i<raw.count;i++) {
CGRect box=PGArrayRect(raw[i][@"box"]);CGPoint center=PGCenter(box);double score;
if(PGValidRect(previousBox)) {
CGFloat factor=PGClamp(s.faceJumpGuardRange,1,4);
CGRect guard=CGRectIntersection(CGRectMake(0,0,1,1),CGRectInset(previousBox,-previousBox.size.width*(factor-1)*.5,-previousBox.size.height*(factor-1)*.5));
if(center.x<CGRectGetMinX(guard)||center.x>CGRectGetMaxX(guard)||center.y<CGRectGetMinY(guard)||center.y>CGRectGetMaxY(guard))continue;
CGPoint p=PGCenter(previousBox);score=-((center.x-p.x)*(center.x-p.x)+(center.y-p.y)*(center.y-p.y));
} else if(mode==1) score=-((center.x-seedX)*(center.x-seedX)+(center.y-seedY)*(center.y-seedY));
else {CGRect clipped=CGRectIntersection(box,CGRectMake(0,0,1,1));CGPoint p=PGCenter(clipped);
score=1.8*fmin(1,clipped.size.width*clipped.size.height)+1.1*(1-fmin(1,hypot(p.x-.5,p.y-.5)/.72));}
if(index<0 || score>best) {index=(NSInteger)i;best=score;}
}
NSArray *output;BOOL held=NO;
if(mode==2) output=raw;
else if(index>=0) {
NSDictionary *current=raw[(NSUInteger)index];
double dt=reference?CMTimeGetSeconds(CMTimeSubtract(time,PGRecordTime(reference))):0;
output=@[previous?PGStableGeometry(current,previous,dt):current];
} else {output=prior?:@[];held=output.count>0;}
NSMutableDictionary *record=[@{@"context":context,@"scope":scope,@"key":key?:@"invalid",@"value":@(time.value),@"scale":@(time.timescale),@"epoch":@(time.epoch),
@"seedX":@(seedX),@"seedY":@(seedY),@"raw":raw,@"output":output,@"index":@(index),@"held":@(held),@"referenceKey":reference[@"key"]?:@""} mutableCopy];
if(key) {
NSData *serialized=[NSPropertyListSerialization dataWithPropertyList:record format:NSPropertyListBinaryFormat_v1_0 options:0 error:nil];
NSUInteger bytes=serialized.length+512;
record[@"bytes"]=@(bytes);
// Replace changed raw geometry at the same timestamp rather than letting
// callbacks grow history or make smoothing depend on redraw count.
for(NSInteger i=(NSInteger)_entries.count-1;i>=0;i--) {
NSDictionary *old=_entries[(NSUInteger)i];
if([old[@"context"] isEqual:context] && [old[@"scope"] isEqual:scope] && [old[@"key"] isEqual:key]) {
_estimatedBytes-=[old[@"bytes"] unsignedIntegerValue];[_entries removeObjectAtIndex:(NSUInteger)i];
}
}
if(bytes<=16*1024*1024) {[_entries addObject:[record copy]];_estimatedBytes+=bytes;}
while(_entries.count>1200 || _estimatedBytes>16*1024*1024) {
_estimatedBytes-=[_entries.firstObject[@"bytes"] unsignedIntegerValue];[_entries removeObjectAtIndex:0];
}
}
[_mutex unlock];return [record copy];
}
@end
@interface PGFaceProcessor () { @interface PGFaceProcessor () {
NSLock *_lock; NSLock *_lock;
NSMutableArray<NSMutableDictionary<NSString *, NSDictionary *> *> *_targets; NSMutableArray<NSMutableDictionary<NSString *, NSDictionary *> *> *_targets;
@@ -126,6 +272,7 @@ static CIImage *PGMaximum(CIImage *a, CIImage *b) {
CIColorKernel *_ellipse; CIColorKernel *_ellipse;
CIColorKernel *_subtract; CIColorKernel *_subtract;
CIColorKernel *_box; CIColorKernel *_box;
PGRealtimeFaceTracker *_realtime;
} }
@end @end
@@ -135,6 +282,7 @@ static CIImage *PGMaximum(CIImage *a, CIImage *b) {
self = [super init]; self = [super init];
if (self) { if (self) {
_lock = [[NSLock alloc] init]; _lock = [[NSLock alloc] init];
_realtime = [PGRealtimeFaceTracker new];
_targets = [NSMutableArray arrayWithObjects:[NSMutableDictionary dictionary], [NSMutableDictionary dictionary], [NSMutableDictionary dictionary], nil]; _targets = [NSMutableArray arrayWithObjects:[NSMutableDictionary dictionary], [NSMutableDictionary dictionary], [NSMutableDictionary dictionary], nil];
// Analytic, feathered sampling deformation authored for this project. // Analytic, feathered sampling deformation authored for this project.
// Positive short-face shortens the lower face; positive chin lengthens it. // Positive short-face shortens the lower face; positive chin lengthens it.
@@ -292,29 +440,56 @@ static CIImage *PGMaximum(CIImage *a, CIImage *b) {
return nil; return nil;
} }
NSDictionary *record = settings.trackingEnabled ? [self recordForTime:time target:MIN((NSUInteger)settings.targetIndex, 2) nearest:NO] : nil; NSDictionary *record = settings.trackingEnabled ? [self recordForTime:time target:MIN((NSUInteger)settings.targetIndex, 2) nearest:NO] : nil;
NSArray<NSDictionary *> *geometry; NSArray<NSDictionary *> *geometry,*appearance;
NSArray<VNFaceObservation *> *observations; NSArray<VNFaceObservation *> *observations;
if (record) { if (record) {
geometry = record[@"faces"]; geometry = record[@"faces"];
appearance = geometry;
NSMutableArray *restored = [NSMutableArray array]; NSMutableArray *restored = [NSMutableArray array];
for (NSDictionary *face in geometry) [restored addObject:[VNFaceObservation faceObservationWithRequestRevision:VNDetectFaceLandmarksRequestRevision3 boundingBox:PGArrayRect(face[@"box"]) roll:nil yaw:nil pitch:nil]]; for (NSDictionary *face in geometry) [restored addObject:[VNFaceObservation faceObservationWithRequestRevision:VNDetectFaceLandmarksRequestRevision3 boundingBox:PGArrayRect(face[@"box"]) roll:nil yaw:nil pitch:nil]];
observations = restored; observations = restored;
} else { } else {
NSArray *detected = [self observationsInImage:image settings:settings error:error]; NSArray *detected = [self observationsInImage:image settings:settings error:error];
if (!detected) return nil; if (!detected) return nil;
// A missing time may use a nearby analyzed identity as a guard, while if(settings.trackingEnabled) {
// still detecting the current image. Rendering never appends to history. // Preserve the explicit offline-analysis contract. Its stored
NSDictionary *anchor = settings.trackingEnabled ? [self recordForTime:time target:MIN((NSUInteger)settings.targetIndex, 2) nearest:YES] : nil; // identity fallback never reads or writes realtime temporal state.
NSDictionary *anchor=[self recordForTime:time target:MIN((NSUInteger)settings.targetIndex,2) nearest:YES];
geometry=[self geometryForObservations:detected settings:settings previous:anchor]; geometry=[self geometryForObservations:detected settings:settings previous:anchor];
NSMutableArray *selected = [NSMutableArray array]; appearance=geometry;
for (NSDictionary *face in geometry) { NSMutableArray *selected=[NSMutableArray new];
CGRect box = PGArrayRect(face[@"box"]); for(NSDictionary *face in geometry) for(VNFaceObservation *observation in detected)
for (VNFaceObservation *observation in detected) if (CGRectEqualToRect(observation.boundingBox, box)) { [selected addObject:observation]; break; } if(CGRectEqualToRect(observation.boundingBox,PGArrayRect(face[@"box"]))) {[selected addObject:observation];break;}
}
observations=selected; observations=selected;
} else {
NSMutableArray *raw=[NSMutableArray new],*validObservations=[NSMutableArray new];
for(VNFaceObservation *observation in detected) if(PGValidRect(observation.boundingBox) && raw.count<16) {
[raw addObject:PGGeometry(observation)];[validObservations addObject:observation];
}
NSDictionary *live=[_realtime resultForGeometry:raw settings:settings time:time extent:extent];
geometry=live[@"output"];
if(settings.singleFace && !settings.fastMultiFace) {
NSInteger index=[live[@"index"] integerValue];
if(index>=0 && (NSUInteger)index<validObservations.count) {
// Retain the selected original Vision observation. A stable
// geometry box need not equal its unsmoothed detection box.
observations=@[validObservations[(NSUInteger)index]];
appearance=@[raw[(NSUInteger)index]];
} else {
appearance=geometry;
NSMutableArray *held=[NSMutableArray new];
for(NSDictionary *face in geometry) [held addObject:[VNFaceObservation faceObservationWithRequestRevision:VNDetectFaceLandmarksRequestRevision3 boundingBox:PGArrayRect(face[@"box"]) roll:nil yaw:nil pitch:nil]];
observations=held;
}
} else {
// Default single-primary geometry does not narrow the separate
// skin/teeth/eyes support, which still covers the detected group.
appearance=raw;observations=validObservations;
}
}
} }
CIImage *skin = PGBlack(extent), *teeth = PGBlack(extent), *eyes = PGBlack(extent), *protection = PGBlack(extent), *overlay = PGTransparent(extent); CIImage *skin = PGBlack(extent), *teeth = PGBlack(extent), *eyes = PGBlack(extent), *protection = PGBlack(extent), *overlay = PGTransparent(extent);
for (NSDictionary *face in geometry) { for (NSDictionary *face in appearance) {
CGRect box = PGArrayRect(face[@"box"]); CGRect box = PGArrayRect(face[@"box"]);
CGRect skinRect = PGInFace(box, -0.10, -0.04, 1.20, 1.16); CGRect skinRect = PGInFace(box, -0.10, -0.04, 1.20, 1.16);
skin = PGMaximum(skin, [self ellipseInRect:PGToPixels(skinRect, extent) extent:extent inner:0.78]); skin = PGMaximum(skin, [self ellipseInRect:PGToPixels(skinRect, extent) extent:extent inner:0.78]);
@@ -334,17 +509,24 @@ static CIImage *PGMaximum(CIImage *a, CIImage *b) {
} }
CGRect mouth = PGRegionBounds(face, @"mouth", PGInFace(box, 0.30, 0.20, 0.40, 0.08)); CGRect mouth = PGRegionBounds(face, @"mouth", PGInFace(box, 0.30, 0.20, 0.40, 0.08));
teeth = PGMaximum(teeth, [self ellipseInRect:PGToPixels(mouth, extent) extent:extent inner:0.35]); teeth = PGMaximum(teeth, [self ellipseInRect:PGToPixels(mouth, extent) extent:extent inner:0.35]);
if (settings.showFaceBoxes || settings.showTrackBox) { if (settings.showFaceBoxes) {
CGRect pixels = PGToPixels(box, extent); CGRect pixels = PGToPixels(box, extent);
CGFloat inset = MAX(1, MIN(pixels.size.width, pixels.size.height) * 0.005); CGFloat inset = MAX(1, MIN(pixels.size.width, pixels.size.height) * 0.005);
CIImage *outline = [_box applyWithExtent:extent arguments:@[[CIVector vectorWithX:CGRectGetMinX(pixels) Y:CGRectGetMinY(pixels) Z:CGRectGetMaxX(pixels) W:CGRectGetMaxY(pixels)], @(inset), [CIVector vectorWithX:0.12 Y:1.0 Z:0.35 W:0.95]]]; CIImage *outline = [_box applyWithExtent:extent arguments:@[[CIVector vectorWithX:CGRectGetMinX(pixels) Y:CGRectGetMinY(pixels) Z:CGRectGetMaxX(pixels) W:CGRectGetMaxY(pixels)], @(inset), [CIVector vectorWithX:0.12 Y:1.0 Z:0.35 W:0.95]]];
overlay = [outline imageByCompositingOverImage:overlay]; overlay = [outline imageByCompositingOverImage:overlay];
} }
} }
if(settings.showTrackBox) for(NSDictionary *face in geometry) {
CGRect pixels=PGToPixels(PGArrayRect(face[@"box"]),extent);
CGFloat inset=MAX(1,MIN(pixels.size.width,pixels.size.height)*.005);
CIImage *outline=[_box applyWithExtent:extent arguments:@[[CIVector vectorWithX:CGRectGetMinX(pixels) Y:CGRectGetMinY(pixels) Z:CGRectGetMaxX(pixels) W:CGRectGetMaxY(pixels)],@(inset),[CIVector vectorWithX:1 Y:.8 Z:.2 W:.95]]];
overlay=[outline imageByCompositingOverImage:overlay];
}
CIImage *colorCoverage=skin; CIImage *colorCoverage=skin;
if (settings.featureProtection) skin = [_subtract applyWithExtent:extent arguments:@[skin, protection]]; if (settings.featureProtection) skin = [_subtract applyWithExtent:extent arguments:@[skin, protection]];
PGFaceFrame *frame = [[PGFaceFrame alloc] init]; PGFaceFrame *frame = [[PGFaceFrame alloc] init];
frame.faces = observations ?: @[]; frame.faces = observations ?: @[];
frame.deformationGeometry=geometry?:@[];
frame.image = [self warpImage:image geometry:geometry settings:settings extent:extent]; frame.image = [self warpImage:image geometry:geometry settings:settings extent:extent];
frame.skinMask = [self warpImage:skin geometry:geometry settings:settings extent:extent]; frame.skinMask = [self warpImage:skin geometry:geometry settings:settings extent:extent];
frame.colorMask = [self warpImage:colorCoverage geometry:geometry settings:settings extent:extent]; frame.colorMask = [self warpImage:colorCoverage geometry:geometry settings:settings extent:extent];
@@ -362,6 +544,7 @@ static CIImage *PGMaximum(CIImage *a, CIImage *b) {
[_lock lock]; [_lock lock];
for (NSMutableDictionary *target in _targets) [target removeAllObjects]; for (NSMutableDictionary *target in _targets) [target removeAllObjects];
[_lock unlock]; [_lock unlock];
[_realtime reset];
} }
- (NSData *)trackingDataForTarget:(NSUInteger)target { - (NSData *)trackingDataForTarget:(NSUInteger)target {
@@ -15,7 +15,7 @@
title.font=[NSFont systemFontOfSize:28 weight:NSFontWeightSemibold];[stack addArrangedSubview:title]; title.font=[NSFont systemFontOfSize:28 weight:NSFontWeightSemibold];[stack addArrangedSubview:title];
NSTextField *subtitle=[NSTextField labelWithString:@"beatygril · FxPlug 4"]; NSTextField *subtitle=[NSTextField labelWithString:@"beatygril · FxPlug 4"];
subtitle.textColor=NSColor.secondaryLabelColor;[stack addArrangedSubview:subtitle]; subtitle.textColor=NSColor.secondaryLabelColor;[stack addArrangedSubview:subtitle];
NSTextField *description=[NSTextField wrappingLabelWithString:@"在 Motion 的滤镜资源库中选择「大鱼 → 大鱼人像美化」。Final Cut Pro 的效果浏览器中也可使用配套效果。\n\n磨皮、肤色、细节和脸型可分别开关。启用「只处理选中人脸」后,在检视器调整选脸位置,或直接点击画面选脸。选定目标后可向前、向后分析并将跟踪数据保存在工程中。"]; NSTextField *description=[NSTextField wrappingLabelWithString:@"在 Motion 的滤镜资源库中选择「大鱼 → 大鱼人像美化」。Final Cut Pro 的效果浏览器中也可使用配套效果。\n\n美化会随画面人脸自动更新,无需先点分析。脸型默认跟随主要人脸,多人模式可处理多张脸。启用「只处理选中人脸」后,可点击画面更换目标。向前/向后分析为可选功能,结果可随工程保存。"];
description.font=[NSFont systemFontOfSize:13];[stack addArrangedSubview:description]; description.font=[NSFont systemFontOfSize:13];[stack addArrangedSubview:description];
NSStackView *buttons=[NSStackView new];buttons.orientation=NSUserInterfaceLayoutOrientationHorizontal;buttons.spacing=12; NSStackView *buttons=[NSStackView new];buttons.orientation=NSUserInterfaceLayoutOrientationHorizontal;buttons.spacing=12;
[buttons addArrangedSubview:[NSButton buttonWithTitle:@"打开 Motion" target:self action:@selector(openMotion:)]]; [buttons addArrangedSubview:[NSButton buttonWithTitle:@"打开 Motion" target:self action:@selector(openMotion:)]];
+190
查看文件
@@ -3,6 +3,16 @@
#import <Vision/Vision.h> #import <Vision/Vision.h>
#import <ImageIO/ImageIO.h> #import <ImageIO/ImageIO.h>
#import "PGFaceProcessor.h" #import "PGFaceProcessor.h"
#import <dispatch/dispatch.h>
#include <math.h>
// Exercise the independent selector with authored landmark geometry, without
// requiring Vision to manufacture a particular jitter or eye-axis failure.
@interface PGRealtimeFaceTracker : NSObject
- (NSDictionary *)resultForGeometry:(NSArray<NSDictionary *> *)raw settings:(PGBeautySettings)settings time:(CMTime)time extent:(CGRect)extent;
- (NSDictionary *)cacheStatistics;
- (void)reset;
@end
// Injectable detections let geometry, selection and saved-frame behavior be // Injectable detections let geometry, selection and saved-frame behavior be
// tested independently of the separate public-domain NASA portrait fixture. // tested independently of the separate public-domain NASA portrait fixture.
@@ -58,6 +68,185 @@ static CIImage *Gradient(void) {
return image; return image;
} }
static NSDictionary *Geometry(CGRect box,double jitter) {
NSMutableDictionary *g=[NSMutableDictionary dictionaryWithObject:@[@(box.origin.x),@(box.origin.y),@(box.size.width),@(box.size.height)] forKey:@"box"];
for(NSString *name in @[@"leftEye",@"rightEye",@"leftBrow",@"rightBrow",@"nose",@"lips",@"mouth",@"contour"]) {
double x=[name isEqual:@"rightEye"]?.7:.3;
g[name]=@[@[@(box.origin.x+(x+jitter)*box.size.width),@(box.origin.y+.65*box.size.height)]];
}
return [g copy];
}
static CGRect GeometryBox(NSDictionary *g) {
NSArray *b=g[@"box"];return b.count==4?CGRectMake([b[0] doubleValue],[b[1] doubleValue],[b[2] doubleValue],[b[3] doubleValue]):CGRectZero;
}
static double EyeX(NSDictionary *g) {return [g[@"leftEye"][0][0] doubleValue];}
static NSDictionary *Live(PGRealtimeFaceTracker *tracker,NSArray *g,PGBeautySettings s,int64_t tick) {
return [tracker resultForGeometry:g settings:s time:CMTimeMake(tick,1000) extent:CGRectMake(0,0,128,128)];
}
static void RealtimeChecks(CIContext *context,CIImage *image) {
NSError *error=nil;PGBeautySettings s=PGBeautyDefaults();
VNFaceObservation *left=Face(CGRectMake(.10,.25,.30,.50));
VNFaceObservation *right=Face(CGRectMake(.66,.30,.20,.40));
PGFaceFixture *p=[PGFaceFixture new];p.detections=@[left,right];
s.faceThin=.8;
PGFaceFrame *automatic=[p processImage:image settings:s time:kCMTimeZero error:&error];
Check(automatic.deformationGeometry.count==1 && GeometryBox(automatic.deformationGeometry.firstObject).origin.x<.5,@"default realtime geometry chooses one area/center primary without analysis");
Check(automatic.faces.count==2 && automatic.faces[0]==left && automatic.faces[1]==right,@"default appearance retains both original Vision observations");
Check(Channel(context,automatic.teethMask,97,51,0)>20 && Channel(context,automatic.eyeMask,92,69,0)>20,@"default teeth and eye support covers the non-primary face");
CGRect rightCrop=CGRectMake(84,38,30,52);
Check([Pixels(context,[automatic.image imageByCroppingToRect:rightCrop]) isEqualToData:Pixels(context,[image imageByCroppingToRect:rightCrop])],@"default deformation leaves the distant secondary face unchanged");
PGBeautySettings multi=s;multi.fastMultiFace=1;multi.singleFace=1;
PGFaceFrame *many=[p processImage:image settings:multi time:kCMTimeZero error:&error];
Check(many.deformationGeometry.count==2 && many.faces.count==2,@"fast-multi takes precedence over a stale single-face flag");
Check(![Pixels(context,[many.image imageByCroppingToRect:rightCrop]) isEqualToData:Pixels(context,[image imageByCroppingToRect:rightCrop])],@"fast-multi visibly deforms the secondary face");
Check([p trackingDataForTarget:0]==nil && [p trackingDataForTarget:1]==nil && [p trackingDataForTarget:2]==nil,@"realtime processing never creates persisted analysis records");
p.detections=@[Face(CGRectMake(.15,.25,.30,.50)),Face(CGRectMake(.43,.005,.56,.99))];
PGFaceFrame *moving=[p processImage:image settings:s time:CMTimeMake(1,25) error:&error];
Check(GeometryBox(moving.deformationGeometry.firstObject).origin.x<.3,@"moving primary stays selected when a much larger stranger appears outside its rectangular guard");
PGFaceFixture *cold=[PGFaceFixture new];cold.detections=p.detections;
PGFaceFrame *coldFrame=[cold processImage:image settings:s time:CMTimeMake(1,25) error:&error];
Check(GeometryBox(coldFrame.deformationGeometry.firstObject).origin.x>.4,@"cold initialization explicitly differs from warm identity association when a new primary dominates");
PGBeautySettings manual=s;manual.singleFace=1;manual.selectionX=.78;manual.selectionY=.5;
p.detections=@[left,right];
PGFaceFrame *chosen=[p processImage:image settings:manual time:CMTimeMake(2,25) error:&error];
Check(chosen.faces.count==1 && chosen.faces.firstObject==right,@"manual selection retains its original observation rather than matching a smoothed box");
Check(GeometryBox(chosen.deformationGeometry.firstObject).origin.x>.5 && Channel(context,chosen.colorMask,26,54,0)==0,@"explicit single-face UX narrows appearance to the selected target");
manual.selectionX=.25;
chosen=[p processImage:image settings:manual time:CMTimeMake(2,25) error:&error];
Check(chosen.faces.firstObject==left,@"manual reselection at the same exact time immediately selects the other face");
PGRealtimeFaceTracker *t=[PGRealtimeFaceTracker new];
NSDictionary *a=Geometry(CGRectMake(.1,.25,.3,.5),0),*b=Geometry(CGRectMake(.66,.3,.2,.4),0);
NSDictionary *r=Live(t,@[a,b],s,0);
Check([r[@"output"] count]==1 && [r[@"index"] integerValue]==0,@"standalone default selector chooses the scored primary");
PGBeautySettings ignoredSeed=s;ignoredSeed.selectionX=.9;ignoredSeed.selectionY=.1;
Check([r isEqual:Live(t,@[a,b],ignoredSeed,0)],@"seed changes are irrelevant while explicit single-face selection is disabled");
NSDictionary *aMoved=Geometry(CGRectMake(.15,.25,.3,.5),0),*large=Geometry(CGRectMake(.43,.005,.56,.99),0);
r=Live(t,@[aMoved,large],s,40);
Check([r[@"index"] integerValue]==0 && [r[@"referenceKey"] isEqual:@"0/1/0"],@"live association uses the previous frame rather than recomputing the primary score");
NSDictionary *outside=Geometry(CGRectMake(.60,.30,.10,.10),0);
r=Live(t,@[outside],s,80);
Check([r[@"held"] boolValue] && [r[@"output"][0] isEqual:Live(t,@[aMoved,large],s,40)[@"output"][0]],@"no guard candidate retains the previous stable geometry at full strength");
r=Live(t,@[],s,120);
Check([r[@"held"] boolValue] && [r[@"referenceKey"] isEqual:@"2/25/0"],@"empty detections hold geometry while advancing output time");
for(int64_t tick=160;tick<=2160;tick+=40)r=Live(t,@[],s,tick);
Check([r[@"held"] boolValue] && [r[@"output"] count]==1,@"continuous close held frames can retain geometry beyond .35 seconds");
r=Live(t,@[],s,3000);
Check([r[@"output"] count]==0 && [r[@"referenceKey"] length]==0,@"a distant seek without a nearby past reference cold-starts without held geometry");
r=Live(t,@[b],s,4000);
Check(![r[@"held"] boolValue] && [r[@"index"] integerValue]==0,@"a far seek with a current face cold-acquires that face");
// Rectangular guard includes a diagonal candidate that a small circular
// center threshold would reject, and excludes a point just over an edge.
PGRealtimeFaceTracker *guard=[PGRealtimeFaceTracker new];
PGBeautySettings guardSettings=s;guardSettings.faceJumpGuardRange=1.8;
NSDictionary *square=Geometry(CGRectMake(.4,.4,.2,.2),0);
Live(guard,@[square],guardSettings,0);
NSDictionary *diagonal=Geometry(CGRectMake(.625,.625,.1,.1),0);
r=Live(guard,@[diagonal],guardSettings,40);
Check(![r[@"held"] boolValue] && [r[@"index"] integerValue]==0,@"expanded rectangular guard accepts a candidate near both positive corners");
[guard reset];Live(guard,@[square],guardSettings,0);
r=Live(guard,@[Geometry(CGRectMake(.631,.45,.1,.1),0)],guardSettings,40);
Check([r[@"held"] boolValue],@"candidate just beyond a rectangular guard edge cannot replace the identity");
PGRealtimeFaceTracker *smooth=[PGRealtimeFaceTracker new];
CGRect box=CGRectMake(.2,.2,.4,.6);
NSDictionary *g0=Geometry(box,0),*jitter=Geometry(box,.06);
Live(smooth,@[g0],s,0);
NSDictionary *smoothed=Live(smooth,@[jitter],s,10);
double expected=EyeX(g0)+(EyeX(jitter)-EyeX(g0))*.3;
Check(fabs(EyeX(smoothed[@"output"][0])-expected)<1e-9,@"small-dt landmark smoothing uses a time-aware current weight");
Check(fabs(EyeX(smoothed[@"output"][0])-EyeX(g0))<fabs(EyeX(jitter)-EyeX(g0)),@"landmark jitter amplitude is reduced");
Check([smoothed isEqual:Live(smooth,@[jitter],s,10)] && [[smooth cacheStatistics][@"frames"] unsignedIntegerValue]==2,@"same exact time and raw geometry returns an identical cached result without another smoothing step");
NSDictionary *equivalent=[smooth resultForGeometry:@[jitter] settings:s time:CMTimeMake(1,100) extent:CGRectMake(0,0,128,128)];
Check([equivalent isEqual:smoothed],@"equivalent rational CMTime representations share one realtime result");
NSDictionary *changed=Geometry(box,.08);
NSDictionary *refreshed=Live(smooth,@[changed],s,10);
Check(fabs(EyeX(refreshed[@"output"][0])-(EyeX(g0)+(EyeX(changed)-EyeX(g0))*.3))<1e-9,@"changed raw detections at the same time recompute from the earlier frame rather than compound smoothing");
NSMutableDictionary *flipped=[changed mutableCopy];flipped[@"leftEye"]=changed[@"rightEye"];flipped[@"rightEye"]=changed[@"leftEye"];
NSDictionary *axis=Live(smooth,@[flipped],s,20);
Check(fabs(EyeX(axis[@"output"][0])-EyeX(refreshed[@"output"][0]))<1e-9,@"eye-axis reversal holds remapped old landmarks instead of flipping the warp");
NSDictionary *translated=Geometry(CGRectOffset(box,.1,0),.08);
NSDictionary *translatedResult=Live(smooth,@[translated],s,60);
Check(fabs(GeometryBox(translatedResult[@"output"][0]).origin.x-.3)<1e-9,@"normal translation uses the current detection box without accumulated box lag");
double mappedEye=EyeX(axis[@"output"][0])+.1;
Check(fabs(EyeX(translatedResult[@"output"][0])-(mappedEye+.9*(EyeX(translated)-mappedEye)))<1e-9,@"old landmarks remap into the current moving box before blending");
Check([smoothed[@"output"] isEqual:Live(smooth,@[jitter],s,10)[@"output"]],@"repeated rewind to a cached time reproduces its original stable geometry");
PGRealtimeFaceTracker *seeds=[PGRealtimeFaceTracker new];
PGBeautySettings selected=s;selected.singleFace=1;selected.selectionX=.25;selected.selectionY=.5;
Live(seeds,@[a,b],selected,0);
selected.selectionX=.255;r=Live(seeds,@[],selected,40);
Check([r[@"held"] boolValue],@"sub-.01 seed movement retains the existing identity");
selected.selectionX=.27;r=Live(seeds,@[],selected,80);
Check([r[@"output"] count]==0 && ![r[@"held"] boolValue],@"seed change over .01 starts a new identity and does not inherit an old held target");
selected.selectionX=.78;r=Live(seeds,@[a,b],selected,80);
Check([r[@"index"] integerValue]==1,@"large manual reselection immediately chooses the new seed target");
NSDictionary *bMoved=Geometry(CGRectMake(.50,.30,.2,.4),0);
r=Live(seeds,@[a,bMoved],selected,120);
Check([r[@"index"] integerValue]==1,@"manually seeded target follows a near moving face");
NSDictionary *bContinued=Geometry(CGRectMake(.45,.3,.2,.4),0),*seedStranger=Geometry(CGRectMake(.8,.3,.15,.4),0);
r=Live(seeds,@[a,bContinued,seedStranger],selected,160);
Check([r[@"index"] integerValue]==1,@"following does not repeatedly select the person nearest the fixed original click");
PGRealtimeFaceTracker *reseedRewind=[PGRealtimeFaceTracker new];
PGBeautySettings seedA=s;seedA.singleFace=1;seedA.selectionX=.2;seedA.selectionY=.5;
for(int64_t i=0;i<=20;i++) {
NSDictionary *movingFace=Geometry(CGRectMake(.15+.02*i,.3,.1,.4),0);
Live(reseedRewind,@[movingFace],seedA,i*40);
}
NSArray *rewindFaces=@[Geometry(CGRectMake(.15,.3,.1,.4),0),Geometry(CGRectMake(.55,.3,.1,.4),0),Geometry(CGRectMake(.75,.3,.1,.4),0)];
NSDictionary *beforeReselect=Live(reseedRewind,rewindFaces,seedA,840);
PGBeautySettings seedB=seedA;seedB.selectionX=.8;Live(reseedRewind,rewindFaces,seedB,900);
Live(reseedRewind,rewindFaces,seedA,1000);
Check([beforeReselect isEqual:Live(reseedRewind,rewindFaces,seedA,840)],@"A-to-B-to-A reselection does not let the future A scope replace an earlier A exact result");
PGRealtimeFaceTracker *order=[PGRealtimeFaceTracker new];
NSDictionary *future=Live(order,@[b],s,1000);
NSDictionary *past=Live(order,@[a],s,0);
Check([past[@"referenceKey"] length]==0 && [past[@"output"][0] isEqual:a],@"uncached rewind never borrows geometry from a future frame");
r=Live(order,@[Geometry(CGRectMake(.64,.3,.2,.4),0)],s,1040);
Check([r[@"referenceKey"] isEqual:future[@"key"]],@"late older frames do not replace the newer live reference head");
NSDictionary *nearRewind=Live(order,@[a],s,980);
Check([nearRewind[@"referenceKey"] length]==0,@"uncached small rewind also refuses a closer future reference");
NSDictionary *epoch=[order resultForGeometry:@[] settings:s time:CMTimeMakeWithEpoch(1080,1000,9) extent:CGRectMake(0,0,128,128)];
Check([epoch[@"output"] count]==0,@"CMTime epochs isolate realtime identity caches");
NSDictionary *extent=[order resultForGeometry:@[] settings:s time:CMTimeMake(1080,1000) extent:CGRectMake(20,30,256,128)];
Check([extent[@"output"] count]==0,@"source extent size and origin changes isolate realtime geometry");
PGBeautySettings target=s;target.targetIndex=1;r=Live(order,@[],target,1080);
Check([r[@"output"] count]==0,@"target changes cannot reuse another target's realtime geometry");
PGBeautySettings fast=s;fast.fastMultiFace=1;r=Live(order,@[a,b],fast,1080);
Check([r[@"output"] count]==2 && [r[@"referenceKey"] length]==0,@"fast-multi mode has a separate geometry context");
r=Live(order,@[],fast,1120);Check([r[@"output"] count]==0,@"fast-multi does not inherit primary hold behavior");
Check([future isEqual:Live(order,@[b],s,1000)],@"same-time historical exact result remains stable after other contexts and seeks");
// Parallel calls may have cold/warm identity differences, but each must be
// data safe, reject future references, and freeze exact repeated requests.
PGRealtimeFaceTracker *parallel=[PGRealtimeFaceTracker new];
dispatch_apply(40,dispatch_get_global_queue(QOS_CLASS_USER_INITIATED,0),^(size_t i) {
@autoreleasepool {Live(parallel,@[a],s,(int64_t)i*40);}
});
BOOL pastOnly=YES,exact=YES;
for(int64_t i=0;i<40;i++) {
NSDictionary *first=Live(parallel,@[a],s,i*40),*again=Live(parallel,@[a],s,i*40);
exact=exact&&[first isEqual:again];
NSString *reference=first[@"referenceKey"];
if(reference.length) {NSArray *parts=[reference componentsSeparatedByString:@"/"];pastOnly=pastOnly&&[parts[0] doubleValue]/[parts[1] doubleValue] <= i*.04;}
}
Check(pastOnly && exact,@"parallel result cache is safe and repeated requests retain past-only references");
PGRealtimeFaceTracker *bounded=[PGRealtimeFaceTracker new];
for(int64_t i=0;i<1210;i++) {@autoreleasepool {Live(bounded,@[],s,i*40);}}
NSDictionary *stats=[bounded cacheStatistics];
Check([stats[@"frames"] unsignedIntegerValue]==1200 && [stats[@"estimatedBytes"] unsignedIntegerValue]<=16*1024*1024,@"realtime geometry cache enforces frame and serialized-size bounds");
[bounded reset];Check([[bounded cacheStatistics][@"frames"] unsignedIntegerValue]==0,@"realtime reset releases cached geometry");
NSData *before=[p trackingDataForTarget:0];
PGBeautySettings offline=s;offline.singleFace=1;offline.trackingEnabled=1;offline.selectionX=.25;offline.selectionY=.5;
p.detections=@[left];Check([p analyzeImage:image settings:offline time:kCMTimeZero error:&error],@"offline analysis remains available beside live history");
NSData *archive=[p trackingDataForTarget:0];
p.detections=@[];[p processImage:image settings:s time:CMTimeMake(3,25) error:&error];
Check(before==nil && [archive isEqual:[p trackingDataForTarget:0]],@"subsequent realtime hold does not mutate persisted offline geometry");
}
int main(int argc, const char *argv[]) { int main(int argc, const char *argv[]) {
@autoreleasepool { @autoreleasepool {
CIContext *context = [CIContext contextWithOptions:@{kCIContextCacheIntermediates:@NO}]; CIContext *context = [CIContext contextWithOptions:@{kCIContextCacheIntermediates:@NO}];
@@ -205,6 +394,7 @@ int main(int argc, const char *argv[]) {
CFRelease(destination); CGImageRelease(preview); CFRelease(destination); CGImageRelease(preview);
CGContextRelease(canvas); CGColorSpaceRelease(space); CGContextRelease(canvas); CGColorSpaceRelease(space);
} }
RealtimeChecks(context,image);
printf("Face processing: %lu checks passed\n", (unsigned long)PGChecks); printf("Face processing: %lu checks passed\n", (unsigned long)PGChecks);
} }
return 0; return 0;