Align 2.4.0 pin tracking and pupil-based eyes; add sequence and target-field regressions
这个提交包含在:
1 父节点
524f536a53
当前提交
75e7f4a00d
26 个文件已更改
+16839
-87
没有与搜索条件相匹配的文件
+237
@@ -0,0 +1,237 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <CoreImage/CoreImage.h>
|
||||
#import <Metal/Metal.h>
|
||||
#include <simd/simd.h>
|
||||
#include <math.h>
|
||||
#import "PGFaceProcessor.h"
|
||||
#import "tracking_fixture.h"
|
||||
|
||||
@interface PGFaceProcessor (EyeTest)
|
||||
- (CIImage *)warpImage:(CIImage *)image geometry:(NSArray<NSDictionary *> *)geometry settings:(PGBeautySettings)s extent:(CGRect)extent;
|
||||
@end
|
||||
static NSUInteger checks;
|
||||
static double maximumCIError,maximumTargetError;
|
||||
static NSMutableArray *caseReports;
|
||||
static void Check(BOOL value,NSString *message) {
|
||||
checks++;if(!value){fprintf(stderr,"FAIL: %s\n",message.UTF8String);exit(1);}
|
||||
}
|
||||
static double Clamp(double x,double a,double b){return fmin(fmax(x,a),b);}
|
||||
static CGPoint ReadEyePoint(NSArray *a){return CGPointMake([a[0] doubleValue],[a[1] doubleValue]);}
|
||||
static NSArray *Pair(CGPoint p){return @[@(p.x),@(p.y)];}
|
||||
static NSDictionary *Geometry(double roll,double scale) {
|
||||
NSMutableDictionary *g=[PGTrackingTestGeometry(CGRectMake(.12,.10,.76,.78),0) mutableCopy];
|
||||
NSMutableArray *points=[g[@"points76"] mutableCopy];
|
||||
for(NSUInteger i=0;i<76;i++) {
|
||||
CGPoint p=ReadEyePoint(points[i]);double x=(p.x-.5)*scale,y=(p.y-.5)*scale;
|
||||
points[i]=Pair(CGPointMake(.5+x*cos(roll)-y*sin(roll),.5+x*sin(roll)+y*cos(roll)));
|
||||
}
|
||||
g[@"points76"]=points;return g;
|
||||
}
|
||||
|
||||
// Independent double-precision scalar specification from the recorded 2.4.0
|
||||
// AIR. It does not call the production kernel or production parameter tables.
|
||||
// Thin uses normalized Y-down distances, while eyes use circular pixel support.
|
||||
static CGPoint Oracle(CGPoint destination,NSDictionary *g,CGRect e,double thin,double eye,BOOL reverseEyes) {
|
||||
static const int origin[17]={75,59,74,60,73,61,72,62,71,63,70,64,69,65,68,66,67};
|
||||
static const int anchor[17]={6,13,47,47,47,47,48,48,48,48,49,49,49,49,52,52,47};
|
||||
CGPoint points[76];for(NSUInteger i=0;i<76;i++){points[i]=ReadEyePoint(g[@"points76"][i]);points[i].y=1-points[i].y;}
|
||||
CGPoint q=CGPointMake((destination.x-e.origin.x)/e.size.width,1-(destination.y-e.origin.y)/e.size.height);
|
||||
double thinAmount=Clamp(thin,0,1)*.025;
|
||||
if(fabs(thinAmount)>1e-6)for(NSUInteger i=0;i<17;i++) {
|
||||
CGPoint a=points[origin[i]],b=points[anchor[i]];double r=hypot(a.x-b.x,a.y-b.y);
|
||||
if(r<=1e-4)continue;
|
||||
double weight=Clamp(1-hypot(q.x-a.x,q.y-a.y)/r,0,1);
|
||||
q.x=Clamp(q.x-(b.x-a.x)*thinAmount*weight,0,1);q.y=Clamp(q.y-(b.y-a.y)*thinAmount*weight,0,1);
|
||||
}
|
||||
double amount=Clamp(eye,0,1)*.15;
|
||||
const int center[2]={6,13},radiusA[2]={0,7},radiusB[2]={1,8};
|
||||
if(amount>1e-6)for(int j=0;j<2;j++) {
|
||||
int i=reverseEyes?1-j:j;CGPoint c=points[center[i]],a=points[radiusA[i]],b=points[radiusB[i]];
|
||||
double radius=hypot((a.x-b.x)*e.size.width,(a.y-b.y)*e.size.height);
|
||||
if(radius<=1e-4)continue;
|
||||
double dx=q.x-c.x,dy=q.y-c.y,distance=hypot(dx*e.size.width,dy*e.size.height);
|
||||
if(distance>radius)continue;
|
||||
double ratio=distance/radius,factor=1-amount*(1-pow(ratio,2));
|
||||
q.x=c.x+dx*factor;q.y=c.y+dy*factor;
|
||||
}
|
||||
return q;
|
||||
}
|
||||
static NSData *CoordinateData(CGSize size) {
|
||||
NSUInteger width=size.width,height=size.height;
|
||||
NSMutableData *data=[NSMutableData dataWithLength:width*height*4*sizeof(float)];float *p=data.mutableBytes;
|
||||
for(NSUInteger y=0;y<height;y++)for(NSUInteger x=0;x<width;x++) {
|
||||
NSUInteger i=(y*width+x)*4;p[i]=(x+.5)/width;p[i+1]=(y+.5)/height;p[i+2]=.25;p[i+3]=1;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
static CIImage *CoordinateImage(CGRect e) {
|
||||
return [[CIImage imageWithBitmapData:CoordinateData(e.size) bytesPerRow:(NSUInteger)e.size.width*4*sizeof(float) size:e.size format:kCIFormatRGBAf colorSpace:nil]
|
||||
imageByApplyingTransform:CGAffineTransformMakeTranslation(e.origin.x,e.origin.y)];
|
||||
}
|
||||
static NSData *Pixels(CIContext *context,CIImage *image) {
|
||||
CGRect e=image.extent;NSUInteger width=e.size.width,height=e.size.height;
|
||||
NSMutableData *data=[NSMutableData dataWithLength:width*height*4*sizeof(float)];
|
||||
[context render:image toBitmap:data.mutableBytes rowBytes:width*4*sizeof(float) bounds:e format:kCIFormatRGBAf colorSpace:nil];return data;
|
||||
}
|
||||
|
||||
// Optional local oracle. Only a path provided explicitly by the developer is
|
||||
// loaded; no vendor binary is included in, copied to, or required by this repo.
|
||||
// AIR metadata: faceThinMode buffer4, sampleFlipY buffer8, warpFlipY buffer9.
|
||||
@interface TargetOracle : NSObject
|
||||
@property(nonatomic,strong)id<MTLDevice> device;
|
||||
@property(nonatomic,strong)id<MTLRenderPipelineState> pipeline;
|
||||
@property(nonatomic,strong)id<MTLCommandQueue> queue;
|
||||
- (instancetype)initWithLibrary:(NSString *)path;
|
||||
- (NSData *)pixelsForGeometry:(NSDictionary *)geometry extent:(CGRect)e thin:(double)thin eye:(double)eye;
|
||||
@end
|
||||
@implementation TargetOracle
|
||||
- (instancetype)initWithLibrary:(NSString *)path {
|
||||
self=[super init];if(!self)return nil;
|
||||
_device=MTLCreateSystemDefaultDevice();NSError *error=nil;
|
||||
id<MTLLibrary> library=[_device newLibraryWithURL:[NSURL fileURLWithPath:path] error:&error];
|
||||
Check(library!=nil,[NSString stringWithFormat:@"optional target library loads: %@",error?:@""]);
|
||||
MTLRenderPipelineDescriptor *pd=[MTLRenderPipelineDescriptor new];
|
||||
pd.vertexFunction=[library newFunctionWithName:@"vertexShader"];
|
||||
pd.fragmentFunction=[library newFunctionWithName:@"faceThinFragmentShader"];
|
||||
pd.colorAttachments[0].pixelFormat=MTLPixelFormatRGBA32Float;
|
||||
_pipeline=[_device newRenderPipelineStateWithDescriptor:pd error:&error];
|
||||
Check(_pipeline!=nil,[NSString stringWithFormat:@"optional target coordinate pipeline loads: %@",error?:@""]);
|
||||
_queue=[_device newCommandQueue];return self;
|
||||
}
|
||||
- (NSData *)pixelsForGeometry:(NSDictionary *)g extent:(CGRect)e thin:(double)thin eye:(double)eye {
|
||||
NSUInteger width=e.size.width,height=e.size.height;
|
||||
MTLTextureDescriptor *td=[MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA32Float width:width height:height mipmapped:NO];
|
||||
td.storageMode=MTLStorageModeShared;td.usage=MTLTextureUsageShaderRead;
|
||||
id<MTLTexture> input=[_device newTextureWithDescriptor:td];NSData *gradient=CoordinateData(e.size);
|
||||
[input replaceRegion:MTLRegionMake2D(0,0,width,height) mipmapLevel:0 withBytes:gradient.bytes bytesPerRow:width*16];
|
||||
td.usage=MTLTextureUsageRenderTarget;id<MTLTexture> output=[_device newTextureWithDescriptor:td];
|
||||
typedef struct {vector_float2 position,textureCoordinate;} Vertex;
|
||||
float w=(float)width,h=(float)height;
|
||||
Vertex vertices[]={{{-w*.5f,h*.5f},{0,0}},{{-w*.5f,-h*.5f},{0,1}},{{w*.5f,h*.5f},{1,0}},{{w*.5f,-h*.5f},{1,1}}};
|
||||
vector_uint2 viewport={(uint32_t)width,(uint32_t)height};vector_float2 points[77];
|
||||
for(NSUInteger i=0;i<76;i++){CGPoint p=ReadEyePoint(g[@"points76"][i]);points[i]=(vector_float2){p.x,1-p.y};}
|
||||
NSArray *box=g[@"box"];points[76]=(vector_float2){[box[0] doubleValue]+[box[2] doubleValue]*.5,1-([box[1] doubleValue]+[box[3] doubleValue]*.5)};
|
||||
int count=1,zero=0;float strength=Clamp(thin,0,1);
|
||||
float shape[10]={Clamp(thin,0,1)*.025,0,0,0,0,0,0,0,0,Clamp(eye,0,1)*.15};
|
||||
id<MTLCommandBuffer> command=[_queue commandBuffer];MTLRenderPassDescriptor *pass=[MTLRenderPassDescriptor renderPassDescriptor];
|
||||
pass.colorAttachments[0].texture=output;pass.colorAttachments[0].loadAction=MTLLoadActionClear;pass.colorAttachments[0].storeAction=MTLStoreActionStore;
|
||||
id<MTLRenderCommandEncoder> encoder=[command renderCommandEncoderWithDescriptor:pass];
|
||||
[encoder setRenderPipelineState:_pipeline];
|
||||
[encoder setVertexBytes:vertices length:sizeof(vertices) atIndex:0];[encoder setVertexBytes:&viewport length:sizeof(viewport) atIndex:1];
|
||||
[encoder setFragmentTexture:input atIndex:0];[encoder setFragmentBytes:points length:sizeof(points) atIndex:0];
|
||||
[encoder setFragmentBytes:&count length:sizeof(count) atIndex:1];[encoder setFragmentBytes:&strength length:sizeof(strength) atIndex:2];
|
||||
[encoder setFragmentBytes:&zero length:sizeof(zero) atIndex:4];[encoder setFragmentBytes:shape length:sizeof(shape) atIndex:7];
|
||||
[encoder setFragmentBytes:&zero length:sizeof(zero) atIndex:8];[encoder setFragmentBytes:&zero length:sizeof(zero) atIndex:9];
|
||||
vector_float2 sampleRect[2]={{0,0},{1,1}};[encoder setFragmentBytes:sampleRect length:sizeof(sampleRect) atIndex:10];
|
||||
[encoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:4];[encoder endEncoding];[command commit];[command waitUntilCompleted];
|
||||
Check(command.status==MTLCommandBufferStatusCompleted,[NSString stringWithFormat:@"optional target executes: %@",command.error?:@""]);
|
||||
NSMutableData *data=[NSMutableData dataWithLength:width*height*4*sizeof(float)];
|
||||
[output getBytes:data.mutableBytes bytesPerRow:width*16 fromRegion:MTLRegionMake2D(0,0,width,height) mipmapLevel:0];return data;
|
||||
}
|
||||
@end
|
||||
|
||||
static NSData *NumericalCase(CIContext *context,PGFaceProcessor *processor,TargetOracle *target,
|
||||
CGRect e,NSDictionary *geometry,double thin,double eye,NSString *name) {
|
||||
PGBeautySettings settings=PGBeautyDefaults();settings.faceThin=thin;settings.faceBigEye=eye;
|
||||
CIImage *output=[processor warpImage:CoordinateImage(e) geometry:@[geometry] settings:settings extent:e];
|
||||
Check(output!=nil&&CGRectEqualToRect(output.extent,e),[name stringByAppendingString:@" preserves extent"]);
|
||||
NSData *data=Pixels(context,output);const float *field=data.bytes;
|
||||
NSUInteger width=e.size.width,height=e.size.height;double worst=0,move=0,worstTarget=0,sum=0;
|
||||
for(NSUInteger y=2;y<height-2;y+=3)for(NSUInteger x=2;x<width-2;x+=3) {
|
||||
CGPoint q=Oracle(CGPointMake(e.origin.x+x+.5,e.origin.y+height-y-.5),geometry,e,thin,eye,NO);
|
||||
double expectedX=Clamp(q.x,.5/width,1-.5/width),expectedY=Clamp(q.y,.5/height,1-.5/height);
|
||||
NSUInteger k=(y*width+x)*4;double error=hypot((field[k]-expectedX)*width,(field[k+1]-expectedY)*height);
|
||||
worst=fmax(worst,error);move=fmax(move,hypot((expectedX-(x+.5)/width)*width,(expectedY-(y+.5)/height)*height));
|
||||
}
|
||||
maximumCIError=fmax(maximumCIError,worst);
|
||||
if(worst>=.035)fprintf(stderr,"%s independent error=%.9g pixels\n",name.UTF8String,worst);
|
||||
Check(worst<.035,[name stringByAppendingString:@" matches independent thin/eye pixel coordinates"]);
|
||||
Check((thin<=0&&eye<=0)||move>.005,[name stringByAppendingString:@" exercises active deformation"]);
|
||||
if(target) {
|
||||
NSData *reference=[target pixelsForGeometry:geometry extent:e thin:thin eye:eye];const float *ref=reference.bytes;
|
||||
for(NSUInteger y=2;y<height-2;y++)for(NSUInteger x=2;x<width-2;x++) {
|
||||
NSUInteger k=(y*width+x)*4;double error=hypot((field[k]-ref[k])*width,(field[k+1]-ref[k+1])*height);
|
||||
worstTarget=fmax(worstTarget,error);sum+=error*error;
|
||||
}
|
||||
maximumTargetError=fmax(maximumTargetError,worstTarget);
|
||||
if(worstTarget>=.035)fprintf(stderr,"%s target error=%.9g pixels\n",name.UTF8String,worstTarget);
|
||||
Check(worstTarget<.035,[name stringByAppendingString:@" matches the local original 2.4.0 Metal field"]);
|
||||
}
|
||||
[caseReports addObject:@{@"name":name,@"width":@(width),@"height":@(height),@"thin":@(thin),@"eye":@(eye),
|
||||
@"maximum_independent_error_pixels":@(worst),@"maximum_displacement_pixels":@(move),
|
||||
@"maximum_target_error_pixels":target?@(worstTarget):NSNull.null,
|
||||
@"target_rms_error_pixels":target?@(sqrt(sum/((width-4)*(height-4)))):NSNull.null}];
|
||||
return data;
|
||||
}
|
||||
int main(int argc,const char **argv){@autoreleasepool {
|
||||
NSString *targetPath=nil,*reportPath=nil;
|
||||
for(int i=1;i<argc;i++) {
|
||||
if(strcmp(argv[i],"--target-metallib")==0&&i+1<argc)targetPath=@(argv[++i]);
|
||||
else if(strcmp(argv[i],"--report")==0&&i+1<argc)reportPath=@(argv[++i]);
|
||||
else {fprintf(stderr,"Usage: eyes-tests [--target-metallib /absolute/default.metallib] [--report output.json]\n");return 2;}
|
||||
}
|
||||
caseReports=[NSMutableArray new];
|
||||
id<MTLDevice> device=MTLCreateSystemDefaultDevice();Check(device!=nil,@"Metal is available for the production Core Image field");
|
||||
CIContext *context=[CIContext contextWithMTLDevice:device options:@{kCIContextWorkingColorSpace:NSNull.null,kCIContextOutputColorSpace:NSNull.null}];
|
||||
PGFaceProcessor *processor=[PGFaceProcessor new];TargetOracle *target=targetPath?[[TargetOracle alloc]initWithLibrary:targetPath]:nil;
|
||||
CGRect extents[]={CGRectMake(0,0,256,256),CGRectMake(0,0,384,216),CGRectMake(-61,35,192,320)};
|
||||
double rolls[]={0,.31,-.46};double strengths[]={.25,.9035486629353234,1};
|
||||
for(NSUInteger pose=0;pose<3;pose++)for(NSUInteger size=0;size<3;size++) {
|
||||
NSDictionary *g=Geometry(rolls[pose],.7+.12*pose);CGRect e=extents[size];
|
||||
NumericalCase(context,processor,target,e,g,0,0,[NSString stringWithFormat:@"pose%lu size%lu passthrough",pose,size]);
|
||||
NumericalCase(context,processor,target,e,g,0,strengths[pose],[NSString stringWithFormat:@"pose%lu size%lu eye-only",pose,size]);
|
||||
NumericalCase(context,processor,target,e,g,1,0,[NSString stringWithFormat:@"pose%lu size%lu thin-only",pose,size]);
|
||||
NumericalCase(context,processor,target,e,g,1,strengths[pose],[NSString stringWithFormat:@"pose%lu size%lu thin+eye",pose,size]);
|
||||
}
|
||||
CGRect e=extents[1];NSDictionary *g=Geometry(.2,.9);
|
||||
NSData *base=NumericalCase(context,processor,target,e,g,0,1,@"indexed eye centers");
|
||||
NSMutableDictionary *changed=[g mutableCopy];changed[@"box"]=@[@.02,@.03,@.93,@.91];changed[@"leftEye"]=@[@[@.8,@.1],@[@.9,@.2]];changed[@"rightEye"]=@[@[@.1,@.1],@[@.2,@.2]];
|
||||
PGBeautySettings onlyEye=PGBeautyDefaults();onlyEye.faceBigEye=1;
|
||||
Check([base isEqualToData:Pixels(context,[processor warpImage:CoordinateImage(e) geometry:@[changed] settings:onlyEye extent:e])],@"eye field is independent of bbox and eye-region bounds when indexed points exist");
|
||||
NumericalCase(context,processor,target,e,g,0,-.5,@"negative eye strength clamps to zero");
|
||||
NSData *clamped=NumericalCase(context,processor,target,e,g,0,2,@"high eye strength clamps to one");
|
||||
Check([base isEqualToData:clamped],@"unit and excessive eye settings produce identical pixels");
|
||||
// Every sampled pixel outside both circles remains exactly on the input
|
||||
// coordinate field. This rejects the previous face-box-sized eye support.
|
||||
const float *basePixels=base.bytes;NSUInteger unaffected=0;double backgroundError=0;
|
||||
CGPoint left=ReadEyePoint(g[@"points76"][6]),right=ReadEyePoint(g[@"points76"][13]);
|
||||
CGPoint la=ReadEyePoint(g[@"points76"][0]),lb=ReadEyePoint(g[@"points76"][1]);
|
||||
CGPoint ra=ReadEyePoint(g[@"points76"][7]),rb=ReadEyePoint(g[@"points76"][8]);
|
||||
double lr=hypot((la.x-lb.x)*e.size.width,(la.y-lb.y)*e.size.height);
|
||||
double rr=hypot((ra.x-rb.x)*e.size.width,(ra.y-rb.y)*e.size.height);
|
||||
for(NSUInteger y=2;y<(NSUInteger)e.size.height-2;y+=3)for(NSUInteger x=2;x<(NSUInteger)e.size.width-2;x+=3) {
|
||||
double py=e.size.height-y-.5,px=x+.5;
|
||||
if(hypot(px-left.x*e.size.width,py-left.y*e.size.height)<=lr+1||hypot(px-right.x*e.size.width,py-right.y*e.size.height)<=rr+1)continue;
|
||||
NSUInteger k=4*(y*(NSUInteger)e.size.width+x);unaffected++;
|
||||
backgroundError=fmax(backgroundError,hypot((basePixels[k]-(x+.5)/e.size.width)*e.size.width,(basePixels[k+1]-(y+.5)/e.size.height)*e.size.height));
|
||||
}
|
||||
Check(unaffected>1000&&backgroundError<.005,@"eye-only deformation preserves the field outside the two pixel circles");
|
||||
NSMutableArray *points=[g[@"points76"] mutableCopy];points[0]=points[1];points[7]=points[8];changed=[g mutableCopy];changed[@"points76"]=points;
|
||||
Check([Pixels(context,CoordinateImage(e)) isEqualToData:Pixels(context,[processor warpImage:CoordinateImage(e) geometry:@[changed] settings:onlyEye extent:e])],@"zero pixel radii leave both eyes unchanged");
|
||||
if(target) {
|
||||
NSData *degenerate=[target pixelsForGeometry:changed extent:e thin:0 eye:1];const float *p=degenerate.bytes,*source=CoordinateData(e.size).bytes;
|
||||
double error=0;for(NSUInteger i=0;i<degenerate.length/sizeof(float);i+=4)error=fmax(error,fabs(p[i]-source[i])+fabs(p[i+1]-source[i+1]));
|
||||
Check(error<.00004,@"local target also treats zero eye radii as identity");
|
||||
}
|
||||
// Force overlap so a reversed eye order cannot accidentally pass the test.
|
||||
points=[g[@"points76"] mutableCopy];points[6]=@[@.47,@.64];points[13]=@[@.53,@.64];
|
||||
points[0]=@[@.40,@.64];points[1]=@[@.56,@.64];points[7]=@[@.44,@.64];points[8]=@[@.60,@.64];
|
||||
changed=[g mutableCopy];changed[@"points76"]=points;
|
||||
NumericalCase(context,processor,target,e,changed,.7,.9,@"overlapping sequential eyes");
|
||||
double orderDifference=0;
|
||||
for(NSUInteger y=2;y<(NSUInteger)e.size.height-2;y+=3)for(NSUInteger x=2;x<(NSUInteger)e.size.width-2;x+=3) {
|
||||
CGPoint position=CGPointMake(e.origin.x+x+.5,e.origin.y+e.size.height-y-.5);
|
||||
CGPoint forward=Oracle(position,changed,e,.7,.9,NO),reverse=Oracle(position,changed,e,.7,.9,YES);
|
||||
orderDifference=fmax(orderDifference,hypot((forward.x-reverse.x)*e.size.width,(forward.y-reverse.y)*e.size.height));
|
||||
}
|
||||
Check(orderDifference>.02,@"overlap fixture distinguishes left-to-right sequential sampling");
|
||||
if(reportPath) {
|
||||
NSDictionary *report=@{@"scope":@"Authored synthetic Vision76 geometry; thin and big-eye controls only; no user media or target binaries embedded",
|
||||
@"target_oracle_executed":@(target!=nil),@"mode_buffer4":@0,@"sample_flip_y_buffer8":@0,@"warp_flip_y_buffer9":@0,
|
||||
@"device":device.name,@"checks":@(checks+1),@"maximum_independent_error_pixels":@(maximumCIError),
|
||||
@"maximum_target_error_pixels":target?@(maximumTargetError):NSNull.null,@"cases":caseReports};
|
||||
NSError *error=nil;NSData *json=[NSJSONSerialization dataWithJSONObject:report options:NSJSONWritingPrettyPrinted|NSJSONWritingSortedKeys error:&error];
|
||||
Check([json writeToFile:reportPath options:NSDataWritingAtomic error:&error],[NSString stringWithFormat:@"write report: %@",error?:@""]);
|
||||
}
|
||||
printf("PASS: %lu eye alignment checks; independent max %.8g px; target %s max %.8g px\n",(unsigned long)checks,maximumCIError,target?"executed":"not requested",maximumTargetError);return 0;
|
||||
}}
|
||||
+36
-32
@@ -5,9 +5,10 @@
|
||||
#import "PGFaceProcessor.h"
|
||||
#import <dispatch/dispatch.h>
|
||||
#include <math.h>
|
||||
#import "tracking_fixture.h"
|
||||
|
||||
// Exercise the independent selector with authored landmark geometry, without
|
||||
// requiring Vision to manufacture a particular jitter or eye-axis failure.
|
||||
// requiring Vision to manufacture a particular jitter or invalid topology.
|
||||
@interface PGRealtimeFaceTracker : NSObject
|
||||
- (NSDictionary *)resultForGeometry:(NSArray<NSDictionary *> *)raw settings:(PGBeautySettings)settings time:(CMTime)time extent:(CGRect)extent;
|
||||
- (NSDictionary *)cacheStatistics;
|
||||
@@ -18,6 +19,7 @@
|
||||
// tested independently of the separate public-domain NASA portrait fixture.
|
||||
@interface PGFaceProcessor (TestDetection)
|
||||
- (NSArray<VNFaceObservation *> *)observationsInImage:(CIImage *)image settings:(PGBeautySettings)settings error:(NSError **)error;
|
||||
- (NSArray<VNFaceObservation *> *)cachedObservationsInImage:(CIImage *)image settings:(PGBeautySettings)settings time:(CMTime)time error:(NSError **)error;
|
||||
@end
|
||||
@interface PGFaceFixture : PGFaceProcessor
|
||||
@property (nonatomic, copy) NSArray<VNFaceObservation *> *detections;
|
||||
@@ -28,6 +30,12 @@
|
||||
self.detectionCount++;
|
||||
return self.detections ?: @[];
|
||||
}
|
||||
// These tests change an injected detector result while reusing one tiny image.
|
||||
// Keep that fixture boundary explicit; tracking_tests exercises the real frame
|
||||
// detection cache separately, including same-time changed detector responses.
|
||||
- (NSArray<VNFaceObservation *> *)cachedObservationsInImage:(CIImage *)image settings:(PGBeautySettings)settings time:(CMTime)time error:(NSError **)error {
|
||||
return [self observationsInImage:image settings:settings error:error];
|
||||
}
|
||||
@end
|
||||
|
||||
static NSUInteger PGChecks = 0;
|
||||
@@ -69,17 +77,17 @@ static CIImage *Gradient(void) {
|
||||
}
|
||||
|
||||
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];
|
||||
return PGTrackingTestGeometry(box,jitter);
|
||||
}
|
||||
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 BOOL SameCompleteShape(NSDictionary *a,NSDictionary *b) {
|
||||
NSArray *x=a[@"points76"],*y=b[@"points76"];if(x.count!=76||y.count!=76)return NO;
|
||||
for(NSUInteger i=0;i<76;i++)for(NSUInteger j=0;j<2;j++)if(fabs([x[i][j] doubleValue]-[y[i][j] doubleValue])>1e-12)return NO;
|
||||
return YES;
|
||||
}
|
||||
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)];
|
||||
}
|
||||
@@ -90,7 +98,7 @@ static void RealtimeChecks(CIContext *context,CIImage *image) {
|
||||
PGFaceFixture *p=[PGFaceFixture new];p.detections=@[left,right];
|
||||
s.faceSmallHead=.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.deformationGeometry.count==0,@"bbox-only fixture cannot seed default deformation without complete valid landmarks");
|
||||
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);
|
||||
@@ -102,18 +110,10 @@ static void RealtimeChecks(CIContext *context,CIImage *image) {
|
||||
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");
|
||||
Check(moving.deformationGeometry.count==0 && moving.faces.count==2,@"successive bbox-only observations retain appearance coverage without inventing a shape");
|
||||
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");
|
||||
Check(coldFrame.deformationGeometry.count==0,@"cold initialization also rejects otherwise valid boxes with missing topology");
|
||||
|
||||
PGRealtimeFaceTracker *t=[PGRealtimeFaceTracker new];
|
||||
NSDictionary *a=Geometry(CGRectMake(.1,.25,.3,.5),0),*b=Geometry(CGRectMake(.66,.3,.2,.4),0);
|
||||
@@ -126,13 +126,15 @@ static void RealtimeChecks(CIContext *context,CIImage *image) {
|
||||
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");
|
||||
Check([r[@"held"] boolValue] && SameCompleteShape(r[@"output"][0],aMoved),@"no guard candidate retains the previous accepted 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");
|
||||
Check([r[@"held"] boolValue] && [r[@"acceptedKey"] isEqual:@"1/25/0"],@"forward holds retain the last accepted detection time separately from output time");
|
||||
[t reset];r=Live(t,@[],s,3000);
|
||||
Check([r[@"output"] count]==0 && [r[@"referenceKey"] length]==0,@"an explicit reset clears held geometry even at a previously rendered time");
|
||||
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");
|
||||
|
||||
@@ -154,23 +156,21 @@ static void RealtimeChecks(CIContext *context,CIImage *image) {
|
||||
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");
|
||||
Check(fabs(EyeX(smoothed[@"output"][0])-EyeX(jitter))<1e-9,@"pin-style accepts a qualified current shape without arbitrary temporal blending");
|
||||
Check([smoothed[@"output"][0][@"points76"] isEqual:jitter[@"points76"]],@"all accepted indexed points remain exactly current rather than partially blended");
|
||||
Check([smoothed isEqual:Live(smooth,@[jitter],s,10)] && [[smooth cacheStatistics][@"frames"] unsignedIntegerValue]==2,@"same exact frame returns an identical frozen result without another tracking 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");
|
||||
Check([refreshed isEqual:smoothed],@"same-frame changed raw observations reuse the previously committed output");
|
||||
NSMutableDictionary *partial=[changed mutableCopy];[partial removeObjectForKey:@"points76"];
|
||||
NSDictionary *heldShape=Live(smooth,@[partial],s,20);
|
||||
Check([heldShape[@"held"] boolValue] && SameCompleteShape(heldShape[@"output"][0],smoothed[@"output"][0]),@"partial topology preserves the last accepted complete shape");
|
||||
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([translatedResult[@"output"][0][@"points76"] isEqual:translated[@"points76"]],@"qualified translating reacquisition adopts the current complete shape exactly");
|
||||
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];
|
||||
@@ -206,7 +206,7 @@ static void RealtimeChecks(CIContext *context,CIImage *image) {
|
||||
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");
|
||||
Check([r[@"referenceKey"] isEqual:past[@"key"]],@"an uncached rewind starts a new forward reference instead of borrowing a cached future 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)];
|
||||
@@ -323,8 +323,12 @@ int main(int argc, const char *argv[]) {
|
||||
*controls[i] = 1;
|
||||
PGFaceFrame *warped = [processor processImage:image settings:changed time:kCMTimeZero error:&error];
|
||||
BOOL changedPixels=![Pixels(context, warped.image) isEqualToData:base];
|
||||
Check(warped != nil && !changedPixels && warped.deformationGeometry.count==0,[NSString stringWithFormat:@"default pin-style rejects bbox-only topology for %@",names[i]]);
|
||||
changed.fastMultiFace=1;
|
||||
PGFaceFrame *legacyWarp=[processor processImage:image settings:changed time:kCMTimeZero error:&error];
|
||||
BOOL legacyChangedPixels=![Pixels(context,legacyWarp.image) isEqualToData:base];
|
||||
BOOL requiresPoints=(i==0 || i==2 || i==4 || i==5 || i==6);
|
||||
Check(warped != nil && (requiresPoints ? !changedPixels : changedPixels), requiresPoints ? @"recovered thin/V/chin/short/jaw skip a face with no indexed landmarks" : [@"geometry control changes pixels: " stringByAppendingString:names[i]]);
|
||||
Check(legacyWarp != nil && (requiresPoints ? !legacyChangedPixels : legacyChangedPixels), requiresPoints ? @"fast-multi recovered thin/V/chin/short/jaw skip a face with no indexed landmarks" : [@"fast-multi preserves legacy bbox geometry control: " stringByAppendingString:names[i]]);
|
||||
Check(CGRectEqualToRect(warped.image.extent, image.extent), @"warp preserves image extent");
|
||||
}
|
||||
settings.faceShort = 0.8;
|
||||
|
||||
+12
-9
@@ -4,6 +4,7 @@
|
||||
#import <ImageIO/ImageIO.h>
|
||||
#import "PGFaceProcessor.h"
|
||||
#include <math.h>
|
||||
#import "tracking_fixture.h"
|
||||
|
||||
@interface PGFaceProcessor (SlimTest)
|
||||
- (CIImage *)warpImage:(CIImage *)image geometry:(NSArray<NSDictionary *> *)geometry settings:(PGBeautySettings)s extent:(CGRect)extent;
|
||||
@@ -51,9 +52,7 @@ static CGPoint Oracle(CGPoint pixel,NSDictionary *g,CGRect extent,double thin) {
|
||||
static const unsigned int anchors[17]={6,13,47,47,47,47,48,48,48,48,49,49,49,49,52,52,47};
|
||||
NSArray *raw=g[@"points76"];CGPoint p[76];
|
||||
for(NSUInteger i=0;i<76;i++){p[i]=ReadPoint(raw[i]);p[i].y=1-p[i].y;}
|
||||
double width=hypot((p[75].x-p[59].x)*extent.size.width,(p[75].y-p[59].y)*extent.size.height);
|
||||
double height=hypot((p[67].x-(p[74].x+p[60].x)/2)*extent.size.width,(p[67].y-(p[74].y+p[60].y)/2)*extent.size.height);
|
||||
double gain=Clamp(280/fmax(1,fmax(width,2*height)),1,2.25)*.025*Clamp(thin,0,1);
|
||||
double gain=.025*Clamp(thin,0,1); // 2.4.0 pin coefficient has no legacy size amplification.
|
||||
CGPoint q=CGPointMake((pixel.x-extent.origin.x)/extent.size.width,1-(pixel.y-extent.origin.y)/extent.size.height);
|
||||
for(NSUInteger i=0;i<17;i++) {
|
||||
CGPoint a=p[origins[i]],b=p[anchors[i]];
|
||||
@@ -399,15 +398,19 @@ int main(int argc,const char *argv[]) {@autoreleasepool {
|
||||
Check(![Pixels(c,[p processImage:image settings:other time:kCMTimeZero error:&error].image) isEqualToData:source],@"other controls remain available in legacy archives");
|
||||
}
|
||||
PGRealtimeFaceTracker *tracker=[PGRealtimeFaceTracker new];PGBeautySettings live=PGBeautyDefaults();
|
||||
NSDictionary *a=[tracker resultForGeometry:@[Geometry(0,0)] settings:live time:kCMTimeZero extent:image.extent];
|
||||
NSDictionary *b=[tracker resultForGeometry:@[Geometry(.2,0)] settings:live time:CMTimeMake(1,25) extent:image.extent];
|
||||
NSDictionary *liveFront=PGTrackingTestGeometry(CGRectMake(.15,.15,.7,.6),0),*liveMoved=PGTrackingTestGeometry(CGRectMake(.16,.15,.7,.6),.003);
|
||||
NSDictionary *a=[tracker resultForGeometry:@[liveFront] settings:live time:kCMTimeZero extent:image.extent];
|
||||
NSDictionary *b=[tracker resultForGeometry:@[liveMoved] settings:live time:CMTimeMake(1,25) extent:image.extent];
|
||||
Check([a[@"output"][0][@"points76"] count]==76 && [b[@"output"][0][@"points76"] count]==76,@"realtime stabilization retains the complete indexed point layout");
|
||||
Check([b isEqual:[tracker resultForGeometry:@[Geometry(.2,0)] settings:live time:CMTimeMake(1,25) extent:image.extent]],@"repeated rendering does not smooth the 76-point layout again");
|
||||
Check([b[@"output"][0][@"points76"] isEqual:liveMoved[@"points76"]],@"qualified realtime points are copied exactly by the pin-style branch");
|
||||
Check([b isEqual:[tracker resultForGeometry:@[liveMoved] settings:live time:CMTimeMake(1,25) extent:image.extent]],@"repeated rendering does not advance the 76-point sequence again");
|
||||
NSDictionary *held=[tracker resultForGeometry:@[] settings:live time:CMTimeMake(2,25) extent:image.extent];
|
||||
Check([held[@"output"][0][@"points76"] isEqual:b[@"output"][0][@"points76"]],@"missing detection holds the complete point layout");
|
||||
NSDictionary *turned=Geometry(1.4,0);
|
||||
NSArray *heldPoints=held[@"output"][0][@"points76"],*acceptedPoints=b[@"output"][0][@"points76"];BOOL sameHeld=heldPoints.count==76&&acceptedPoints.count==76;
|
||||
for(NSUInteger i=0;i<MIN(heldPoints.count,acceptedPoints.count);i++) {CGPoint h=ReadPoint(heldPoints[i]),a=ReadPoint(acceptedPoints[i]);sameHeld=sameHeld&&hypot(h.x-a.x,h.y-a.y)<1e-12;}
|
||||
Check(sameHeld,@"missing detection holds the complete point layout through affine roundoff");
|
||||
NSDictionary *turned=PGTrackingTestGeometry(CGRectMake(.16,.15,.7,.6),.02);
|
||||
NSDictionary *back=[tracker resultForGeometry:@[turned] settings:live time:CMTimeMake(3,25) extent:image.extent];
|
||||
Check([back[@"output"][0][@"points76"] isEqual:turned[@"points76"]],@"reacquired side-face points do not blend with a held frontal pose");
|
||||
Check([back[@"output"][0][@"points76"] isEqual:turned[@"points76"]],@"qualified reacquired points do not blend with the held pose");
|
||||
for(int index=1;index<argc;index++) {@autoreleasepool {
|
||||
CIImage *portrait=[CIImage imageWithContentsOfURL:[NSURL fileURLWithPath:@(argv[index])] options:@{kCIImageColorSpace:NSNull.null}];
|
||||
PGFaceProcessor *real=[PGFaceProcessor new];PGBeautySettings rs=PGBeautyDefaults();rs.faceThin=1;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Entirely authored geometry in the public Vision76 index layout. No image or
|
||||
// landmark coordinates from a person or the reference plug-in are embedded.
|
||||
static NSDictionary *PGTrackingTestGeometry(CGRect box, double horizontalShift) {
|
||||
NSMutableArray *points=[NSMutableArray new];
|
||||
for(NSUInteger i=0;i<76;i++)[points addObject:@[@.5,@.5]];
|
||||
const NSUInteger leftEye[]={0,4,5,1,3,2},rightEye[]={7,11,12,8,10,9};
|
||||
for(NSUInteger i=0;i<6;i++) {
|
||||
double a=2*M_PI*i/6;
|
||||
points[leftEye[i]]=@[@(.30+.09*cos(a)),@(.70+.035*sin(a))];
|
||||
points[rightEye[i]]=@[@(.70+.09*cos(a)),@(.70+.035*sin(a))];
|
||||
}
|
||||
points[6]=@[@.30,@.70];points[13]=@[@.70,@.70];
|
||||
for(NSUInteger i=14;i<20;i++)points[i]=@[@(.20+.04*(i-14)),@(.82+.025*sin(M_PI*(i-14)/5))];
|
||||
for(NSUInteger i=20;i<26;i++)points[i]=@[@(.60+.04*(i-20)),@(.82+.025*sin(M_PI*(i-20)/5))];
|
||||
const NSUInteger outer[]={27,28,29,30,31,32,33,34,35,36,37,38,39,26};
|
||||
for(NSUInteger i=0;i<14;i++){double a=M_PI-2*M_PI*i/14;points[outer[i]]=@[@(.5+.17*cos(a)),@(.33+.055*sin(a))];}
|
||||
const NSUInteger inner[]={42,40,43,45,41,44};
|
||||
for(NSUInteger i=0;i<6;i++){double a=M_PI-2*M_PI*i/6;points[inner[i]]=@[@(.5+.10*cos(a)),@(.33+.025*sin(a))];}
|
||||
points[46]=@[@.5,@.71];points[47]=@[@.5,@.61];points[48]=@[@.5,@.55];points[49]=@[@.5,@.49];
|
||||
points[50]=@[@.58,@.49];points[51]=@[@.55,@.46];points[52]=@[@.5,@.45];points[53]=@[@.45,@.46];
|
||||
points[54]=@[@.42,@.49];points[55]=@[@.46,@.51];points[56]=@[@.54,@.51];points[57]=@[@.5,@.46];points[58]=@[@.5,@.47];
|
||||
for(NSUInteger i=0;i<17;i++){double a=M_PI+M_PI*i/16;points[59+i]=@[@(.5+.46*cos(a)),@(.73+.69*sin(a))];}
|
||||
for(NSUInteger i=0;i<76;i++)points[i]=@[@(box.origin.x+([points[i][0] doubleValue]+horizontalShift)*box.size.width),@(box.origin.y+[points[i][1] doubleValue]*box.size.height)];
|
||||
NSMutableDictionary *g=[@{@"box":@[@(box.origin.x),@(box.origin.y),@(box.size.width),@(box.size.height)],@"points76":points,@"landmarkConfidence":@.99} mutableCopy];
|
||||
NSArray *names=@[@"leftEye",@"rightEye",@"leftBrow",@"rightBrow",@"nose",@"lips",@"mouth",@"contour"];
|
||||
NSArray *indices=@[@[@0,@4,@5,@1,@3,@2],@[@7,@11,@12,@8,@10,@9],@[@14,@15,@16,@17,@18,@19],@[@20,@21,@22,@23,@24,@25],@[@47,@56,@54,@53,@52,@51,@50,@55],@[@27,@28,@29,@30,@31,@32,@33,@34,@35,@36,@37,@38,@39,@26],@[@42,@40,@43,@45,@41,@44],@[@59,@60,@61,@62,@63,@64,@65,@66,@67,@68,@69,@70,@71,@72,@73,@74,@75]];
|
||||
for(NSUInteger i=0;i<names.count;i++){NSMutableArray *region=[NSMutableArray new];for(NSNumber *n in indices[i])[region addObject:points[n.unsignedIntegerValue]];g[names[i]]=region;}
|
||||
return [g copy];
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// Contract tests for the 2.4.0 pin-style reconstruction. The source is imported
|
||||
// once so the quality gate and actual tracker can be exercised without a host,
|
||||
// real portrait, private Vision construction, or reference binary execution.
|
||||
#import "../FCP-BeautyGirl/FCP-BeautyGirl/Plugin/PGFaceProcessor.m"
|
||||
#import "tracking_fixture.h"
|
||||
|
||||
static NSUInteger checks=0,failures=0;
|
||||
static void Check(BOOL ok,NSString *message){checks++;if(!ok){failures++;fprintf(stderr,"FAIL: %s\n",message.UTF8String);}}
|
||||
static BOOL EqualPoints(NSArray *a,NSArray *b,double tolerance){
|
||||
if(a.count!=76||b.count!=76)return NO;
|
||||
for(NSUInteger i=0;i<76;i++)for(NSUInteger j=0;j<2;j++)if(fabs([a[i][j] doubleValue]-[b[i][j] doubleValue])>tolerance)return NO;
|
||||
return YES;
|
||||
}
|
||||
static NSDictionary *First(NSDictionary *result){return [result[@"output"] firstObject];}
|
||||
static NSDictionary *Live(PGRealtimeFaceTracker *t,NSArray *g,PGBeautySettings s,int64_t tick){return[t resultForGeometry:g settings:s time:CMTimeMake(tick,1000) extent:CGRectMake(0,0,256,256)];}
|
||||
static NSArray *MappedPoints(NSDictionary *g,CGRect box){
|
||||
CGRect old=PGArrayRect(g[@"box"]);NSMutableArray *a=[NSMutableArray new];
|
||||
for(NSArray *point in g[@"points76"]){CGPoint p=PGRemapPoint(point,old,box);[a addObject:@[@(p.x),@(p.y)]];}return a;
|
||||
}
|
||||
static NSDictionary *Without76(NSDictionary *g){NSMutableDictionary *bad=[g mutableCopy];[bad removeObjectForKey:@"points76"];return bad;}
|
||||
static NSDictionary *WithPoints(NSDictionary *g,void (^change)(NSMutableArray *)){
|
||||
NSMutableDictionary *bad=[g mutableCopy];NSMutableArray *p=[g[@"points76"] mutableCopy];change(p);bad[@"points76"]=p;return bad;
|
||||
}
|
||||
|
||||
@interface PGScriptedTracker : PGRealtimeFaceTracker
|
||||
@property CGRect proposedBox;
|
||||
@property NSUInteger followCalls;
|
||||
@property NSUInteger seedCalls;
|
||||
@end
|
||||
@implementation PGScriptedTracker
|
||||
- (instancetype)init{if((self=[super init]))_proposedBox=CGRectNull;return self;}
|
||||
- (CGRect)trackedBoxForImage:(CIImage *)image time:(CMTime)time seedBox:(CGRect)seed {
|
||||
if(PGValidRect(seed)){self.seedCalls++;return seed;}
|
||||
self.followCalls++;return self.proposedBox;
|
||||
}
|
||||
@end
|
||||
|
||||
@interface PGCountingDetector : PGFaceProcessor
|
||||
@property (nonatomic,copy) NSArray<VNFaceObservation *> *suppliedFaces;
|
||||
@property NSUInteger calls;
|
||||
@property BOOL fail;
|
||||
@end
|
||||
@implementation PGCountingDetector
|
||||
- (NSArray<VNFaceObservation *> *)observationsInImage:(CIImage *)image settings:(PGBeautySettings)s error:(NSError **)error {
|
||||
self.calls++;if(self.fail){if(error)*error=[NSError errorWithDomain:@"synthetic-detection" code:1 userInfo:nil];return nil;}return self.suppliedFaces?:@[];
|
||||
}
|
||||
@end
|
||||
@interface PGScriptedOutputTracker : PGRealtimeFaceTracker
|
||||
@property (nonatomic,copy) NSDictionary *scriptedOutput;
|
||||
@end
|
||||
@implementation PGScriptedOutputTracker
|
||||
- (NSDictionary *)resultForGeometry:(NSArray *)raw settings:(PGBeautySettings)s time:(CMTime)time extent:(CGRect)extent image:(CIImage *)image {return self.scriptedOutput;}
|
||||
@end
|
||||
|
||||
// Objective-C message-compatible test doubles, never supplied to Vision. They
|
||||
// expose only the public read-only observation/region selectors used by the
|
||||
// production geometry converter, backed entirely by our authored fixture.
|
||||
@interface PGAuthoredRegion : NSObject
|
||||
@property (nonatomic,strong) NSData *storage;
|
||||
@property NSUInteger pointCount;
|
||||
- (const CGPoint *)normalizedPoints;
|
||||
@end
|
||||
@implementation PGAuthoredRegion
|
||||
- (const CGPoint *)normalizedPoints{return self.storage.bytes;}
|
||||
@end
|
||||
@interface PGAuthoredLandmarks : NSObject
|
||||
@property (nonatomic,strong) PGAuthoredRegion *allPoints,*leftEye,*rightEye,*leftEyebrow,*rightEyebrow,*nose,*outerLips,*innerLips,*faceContour;
|
||||
@property VNConfidence confidence;
|
||||
@end
|
||||
@implementation PGAuthoredLandmarks @end
|
||||
@interface PGAuthoredObservation : NSObject
|
||||
@property CGRect boundingBox;
|
||||
@property (nonatomic,strong) PGAuthoredLandmarks *landmarks;
|
||||
@property VNConfidence confidence;
|
||||
@end
|
||||
@implementation PGAuthoredObservation @end
|
||||
static PGAuthoredRegion *AuthoredRegion(NSArray *points,CGRect box){
|
||||
NSMutableData *data=[NSMutableData dataWithLength:points.count*sizeof(CGPoint)];CGPoint *p=data.mutableBytes;
|
||||
for(NSUInteger i=0;i<points.count;i++)p[i]=PGLocalPoint(CGPointMake([points[i][0] doubleValue],[points[i][1] doubleValue]),box);
|
||||
PGAuthoredRegion *region=[PGAuthoredRegion new];region.storage=data;region.pointCount=points.count;return region;
|
||||
}
|
||||
static PGAuthoredObservation *AuthoredObservation(NSDictionary *g){
|
||||
CGRect box=PGArrayRect(g[@"box"]);PGAuthoredLandmarks *l=[PGAuthoredLandmarks new];l.confidence=.99;
|
||||
l.allPoints=AuthoredRegion(g[@"points76"],box);l.leftEye=AuthoredRegion(g[@"leftEye"],box);l.rightEye=AuthoredRegion(g[@"rightEye"],box);
|
||||
l.leftEyebrow=AuthoredRegion(g[@"leftBrow"],box);l.rightEyebrow=AuthoredRegion(g[@"rightBrow"],box);l.nose=AuthoredRegion(g[@"nose"],box);
|
||||
l.outerLips=AuthoredRegion(g[@"lips"],box);l.innerLips=AuthoredRegion(g[@"mouth"],box);l.faceContour=AuthoredRegion(g[@"contour"],box);
|
||||
PGAuthoredObservation *o=[PGAuthoredObservation new];o.boundingBox=box;o.landmarks=l;o.confidence=.99;return o;
|
||||
}
|
||||
|
||||
static void QualityAndAcceptance(void){
|
||||
CGRect box=CGRectMake(.15,.12,.70,.78);NSDictionary *good=PGTrackingTestGeometry(box,0);
|
||||
Check(PGGeometryQualityReason(good).length==0,@"authored public-layout geometry passes the recovered quality gate");
|
||||
NSMutableDictionary *legacy=[good mutableCopy];[legacy removeObjectForKey:@"landmarkConfidence"];
|
||||
Check(PGGeometryQualityReason(legacy).length==0,@"missing confidence metadata keeps authored/legacy geometry usable");
|
||||
NSMutableDictionary *low=[good mutableCopy];low[@"landmarkConfidence"]=@.2;
|
||||
NSMutableDictionary *shortContour=[good mutableCopy];shortContour[@"contour"]=[good[@"contour"] subarrayWithRange:NSMakeRange(0,8)];
|
||||
NSMutableDictionary *eyes=[good mutableCopy];eyes[@"rightEye"]=eyes[@"leftEye"];
|
||||
NSMutableDictionary *mouth=[good mutableCopy];mouth[@"lips"]=mouth[@"leftEye"];
|
||||
NSMutableDictionary *nose=[good mutableCopy];nose[@"nose"]=@[@[@.5,@.99]];
|
||||
NSArray *badCases=@[Without76(good),WithPoints(good,^(NSMutableArray *p){p[65]=@[@(NAN),@.5];}),WithPoints(good,^(NSMutableArray *p){p[65]=@[@3,@.5];}),low,shortContour,eyes,mouth,nose];
|
||||
PGBeautySettings s=PGBeautyDefaults();s.faceThin=1;
|
||||
for(NSUInteger i=0;i<badCases.count;i++){
|
||||
NSDictionary *bad=badCases[i];
|
||||
Check(PGGeometryQualityReason(bad).length>0,[NSString stringWithFormat:@"quality rejects malformed case %lu",(unsigned long)i]);
|
||||
PGRealtimeFaceTracker *t=[PGRealtimeFaceTracker new];Live(t,@[good],s,0);NSDictionary *r=Live(t,@[bad],s,40);
|
||||
Check([r[@"held"] boolValue] && EqualPoints(First(r)[@"points76"],good[@"points76"],1e-12),[NSString stringWithFormat:@"malformed case %lu holds the last accepted complete shape",(unsigned long)i]);
|
||||
PGRealtimeFaceTracker *cold=[PGRealtimeFaceTracker new];NSDictionary *initial=Live(cold,@[bad],s,0);
|
||||
Check([First(initial)[@"points76"] count]==0,[NSString stringWithFormat:@"cold malformed case %lu never invents topology",(unsigned long)i]);
|
||||
}
|
||||
for(NSNumber *fps in @[@25,@30,@60,@120]){
|
||||
PGRealtimeFaceTracker *t=[PGRealtimeFaceTracker new];Live(t,@[good],s,0);NSDictionary *next=PGTrackingTestGeometry(box,.002);
|
||||
NSDictionary *r=[t resultForGeometry:@[next] settings:s time:CMTimeMake(1,fps.intValue) extent:CGRectMake(0,0,256,256)];
|
||||
Check(![r[@"held"] boolValue] && EqualPoints(First(r)[@"points76"],next[@"points76"],0),[NSString stringWithFormat:@"accepted %ldfps points are copied exactly without legacy temporal blending",(long)fps.integerValue]);
|
||||
}
|
||||
PGRealtimeFaceTracker *t=[PGRealtimeFaceTracker new];Live(t,@[good],s,0);NSDictionary *middle=Live(t,@[Without76(good)],s,40),*back=Live(t,@[good],s,80);
|
||||
Check([middle[@"held"] boolValue] && [First(middle)[@"points76"] count]==76 && EqualPoints(First(back)[@"points76"],good[@"points76"],0),@"76 to missing76 to76 never disables the geometric field");
|
||||
NSDictionary *jumpy=PGTrackingTestGeometry(box,.34);
|
||||
Check(PGGeometryQualityReason(jumpy).length==0,@"large coherent local jump remains structurally valid before temporal compatibility checks");
|
||||
PGRealtimeFaceTracker *jump=[PGRealtimeFaceTracker new];Live(jump,@[good],s,0);NSDictionary *rejected=Live(jump,@[jumpy],s,40);
|
||||
Check([rejected[@"held"] boolValue] && EqualPoints(First(rejected)[@"points76"],good[@"points76"],1e-12),@"a large finite median jump cannot poison accepted shape memory");
|
||||
NSDictionary *axisReverse=WithPoints(good,^(NSMutableArray *p){for(NSUInteger i=66;i<=68;i++)p[i]=@[p[i][0],@(2*CGRectGetMidY(box)-[p[i][1] doubleValue])];});
|
||||
Check(PGGeometryAxisFlip(axisReverse,good),@"weighted lower-contour to bbox-center axis detects a vertical sign reversal");
|
||||
NSMutableDictionary *eyeReverse=[good mutableCopy];eyeReverse[@"leftEye"]=good[@"rightEye"];eyeReverse[@"rightEye"]=good[@"leftEye"];
|
||||
Check(!PGGeometryAxisFlip(eyeReverse,good),@"an isolated eye-order change is not the recovered lower-contour axis test");
|
||||
NSDictionary *fewOutliers=WithPoints(good,^(NSMutableArray *p){for(NSUInteger i=0;i<6;i++)p[i]=@[@([p[i][0] doubleValue]+.55),p[i][1]];});
|
||||
NSDictionary *manyOutliers=WithPoints(good,^(NSMutableArray *p){for(NSUInteger i=0;i<12;i++)p[i]=@[@([p[i][0] doubleValue]+.55),p[i][1]];});
|
||||
Check(PGGeometryTransitionReason(fewOutliers,good).length==0,@"the temporal quantile gate does not treat a few outliers as a large median or P90 jump");
|
||||
Check(PGGeometryTransitionReason(manyOutliers,good).length>0,@"P90 rejects a substantial outlier group even when the median remains zero");
|
||||
}
|
||||
|
||||
static void SequenceAndCache(void){
|
||||
PGBeautySettings s=PGBeautyDefaults();s.faceThin=1;CGRect box=CGRectMake(.15,.12,.70,.78);
|
||||
NSDictionary *good=PGTrackingTestGeometry(box,0),*changed=PGTrackingTestGeometry(box,.003);CIImage *image=[[CIImage imageWithColor:[CIColor colorWithRed:.5 green:.5 blue:.5]] imageByCroppingToRect:CGRectMake(0,0,256,256)];
|
||||
PGScriptedTracker *t=[PGScriptedTracker new];NSDictionary *first=[t resultForGeometry:@[good] settings:s time:kCMTimeZero extent:image.extent image:image];
|
||||
Check(t.seedCalls>0,@"first accepted face seeds the continuous object-box tracker");
|
||||
CGRect tracked=CGRectMake(.17,.13,.66,.74);t.proposedBox=tracked;
|
||||
NSDictionary *hold=[t resultForGeometry:@[Without76(good)] settings:s time:CMTimeMake(1,25) extent:image.extent image:image];
|
||||
Check(t.followCalls>0 && [hold[@"held"] boolValue],@"a partial detection asks the continuous sequence tracker for a box");
|
||||
Check(CGRectEqualToRect(PGArrayRect(First(hold)[@"box"]),tracked) && EqualPoints(First(hold)[@"points76"],MappedPoints(good,tracked),1e-12),@"held complete points follow the tracked box with exact affine remapping");
|
||||
NSUInteger calls=t.followCalls+t.seedCalls;
|
||||
NSDictionary *repeat=[t resultForGeometry:@[changed] settings:s time:CMTimeMake(2,50) extent:image.extent image:image];
|
||||
Check([repeat isEqual:hold] && t.followCalls+t.seedCalls==calls,@"same rational frame freezes its output and does not advance Vision again when raw changes");
|
||||
t.proposedBox=CGRectNull;NSDictionary *reacquired=[t resultForGeometry:@[changed] settings:s time:CMTimeMake(2,25) extent:image.extent image:image];
|
||||
Check(![reacquired[@"held"] boolValue] && EqualPoints(First(reacquired)[@"points76"],changed[@"points76"],0),@"a good reacquired detection replaces held shape exactly");
|
||||
NSDictionary *miss=Live(t,@[],s,120);Check([miss[@"held"] boolValue] && [First(miss)[@"points76"] count]==76,@"nearby complete detection loss preserves complete accepted geometry");
|
||||
PGRealtimeFaceTracker *cold=[PGRealtimeFaceTracker new];NSDictionary *empty=Live(cold,@[],s,40);Live(cold,@[good],s,0);
|
||||
Check([empty isEqual:Live(cold,@[changed],s,40)],@"same-frame freeze is stable when an earlier request arrives later");
|
||||
PGRealtimeFaceTracker *past=[PGRealtimeFaceTracker new];Live(past,@[good],s,1000);NSDictionary *rewind=Live(past,@[],s,960);
|
||||
Check([rewind[@"output"] count]==0,@"uncached reverse request cannot inherit a future accepted face");
|
||||
PGRealtimeFaceTracker *scope=[PGRealtimeFaceTracker new];Live(scope,@[good],s,0);
|
||||
Check([[scope resultForGeometry:@[] settings:s time:CMTimeMakeWithEpoch(40,1000,9) extent:image.extent][@"output"] count]==0,@"CMTime epoch changes isolate accepted geometry");
|
||||
Check([[scope resultForGeometry:@[] settings:s time:CMTimeMake(40,1000) extent:CGRectMake(10,0,256,256)][@"output"] count]==0,@"image origin changes isolate accepted geometry");
|
||||
PGBeautySettings multi=s;multi.fastMultiFace=1;NSDictionary *many=Live(scope,@[good,Without76(changed)],multi,40);
|
||||
Check([many[@"output"] count]==2,@"fast-multi retains its independent legacy raw-geometry contract");
|
||||
Check([Live(scope,@[],multi,80)[@"output"] count]==0,@"fast-multi cannot inherit the default primary-face hold");
|
||||
NSDictionary *defaultAgain=Live(scope,@[],s,80);Check([First(defaultAgain)[@"points76"] count]==76,@"fast-multi requests do not overwrite default accepted memory");
|
||||
[scope reset];Check([Live(scope,@[],s,120)[@"output"] count]==0,@"reset removes both output history and accepted shape state");
|
||||
Check(EqualPoints(First(first)[@"points76"],good[@"points76"],0),@"the first sequence frame preserves every authored point");
|
||||
}
|
||||
|
||||
// Exercise the real VNSequenceRequestHandler/VNTrackObjectRequest on a textured
|
||||
// synthetic rectangle. This verifies API wiring; it is not portrait accuracy or
|
||||
// target parity. No face detector or media file is required.
|
||||
static CIImage *ObjectFrame(NSUInteger frame){
|
||||
NSMutableData *d=[NSMutableData dataWithLength:256*256*4];uint8_t *p=d.mutableBytes;NSInteger dx=(NSInteger)frame*2;
|
||||
for(NSInteger y=0;y<256;y++)for(NSInteger x=0;x<256;x++){
|
||||
NSUInteger i=4*(y*256+x);NSInteger u=x-76-dx,v=y-51;BOOL inside=u>=0&&u<90&&v>=0&&v<128;
|
||||
uint8_t texture=inside?(uint8_t)(35+(u*37+v*53+((u/7+v/9)%2)*83)%210):8;
|
||||
p[i]=texture;p[i+1]=inside?(uint8_t)(50+(u*17+v*13)%180):12;p[i+2]=inside?(uint8_t)(40+(u*11+v*29)%200):16;p[i+3]=255;
|
||||
}
|
||||
return[CIImage imageWithBitmapData:d bytesPerRow:1024 size:CGSizeMake(256,256) format:kCIFormatRGBA8 colorSpace:NULL];
|
||||
}
|
||||
static double MaskPeak(CIContext *context,CIImage *image){
|
||||
CGRect extent=image.extent;NSUInteger width=extent.size.width,height=extent.size.height;
|
||||
NSMutableData *d=[NSMutableData dataWithLength:width*height*4*sizeof(float)];
|
||||
[context render:image toBitmap:d.mutableBytes rowBytes:width*4*sizeof(float) bounds:extent format:kCIFormatRGBAf colorSpace:NULL];
|
||||
const float *p=d.bytes;double peak=0;for(NSUInteger i=0;i<width*height;i++)peak=MAX(peak,p[4*i]);return peak;
|
||||
}
|
||||
static void DetectionCacheAndHeldMasks(void){
|
||||
CIImage *image=ObjectFrame(0);PGBeautySettings s=PGBeautyDefaults();PGCountingDetector *p=[PGCountingDetector new];
|
||||
VNFaceObservation *face=[VNFaceObservation faceObservationWithRequestRevision:VNDetectFaceLandmarksRequestRevision3 boundingBox:CGRectMake(.15,.12,.7,.78) roll:nil yaw:nil pitch:nil];
|
||||
p.suppliedFaces=@[face];NSArray *first=[p cachedObservationsInImage:image settings:s time:CMTimeMake(1,25) error:nil];p.suppliedFaces=@[];
|
||||
NSArray *again=[p cachedObservationsInImage:image settings:s time:CMTimeMake(2,50) error:nil];
|
||||
Check(first.count==1 && [again isEqual:first] && p.calls==1,@"equivalent same-frame requests reuse detection before running Vision again");
|
||||
s.faceThin=1;Check([[p cachedObservationsInImage:image settings:s time:CMTimeMake(1,25) error:nil] isEqual:first] && p.calls==1,@"shape parameter redraws do not invalidate same-frame face detection");
|
||||
NSArray *empty=[p cachedObservationsInImage:image settings:s time:CMTimeMake(2,25) error:nil];p.suppliedFaces=@[face];
|
||||
Check(empty.count==0 && [[p cachedObservationsInImage:image settings:s time:CMTimeMake(2,25) error:nil] count]==0 && p.calls==2,@"successful empty detection is frozen for redraws rather than toggling appearance");
|
||||
[p cachedObservationsInImage:image settings:s time:CMTimeMakeWithEpoch(2,25,8) error:nil];Check(p.calls==3,@"detection cache distinguishes CMTime epochs");
|
||||
CIImage *offset=[image imageByApplyingTransform:CGAffineTransformMakeTranslation(10,20)];[p cachedObservationsInImage:offset settings:s time:CMTimeMake(2,25) error:nil];
|
||||
Check(p.calls==4,@"detection cache distinguishes source extent origin");
|
||||
s.fastMultiFace=1;[p cachedObservationsInImage:image settings:s time:CMTimeMake(2,25) error:nil];Check(p.calls==5,@"different detection-resolution modes cannot share cached observations");
|
||||
[p resetTracking];[p cachedObservationsInImage:image settings:s time:CMTimeMake(2,25) error:nil];Check(p.calls==6,@"reset also clears observation cache entries");
|
||||
PGCountingDetector *retry=[PGCountingDetector new];retry.fail=YES;Check([retry cachedObservationsInImage:image settings:s time:kCMTimeZero error:nil]==nil,@"transient detector error remains an error rather than a successful empty result");
|
||||
retry.fail=NO;retry.suppliedFaces=@[face];Check([[retry cachedObservationsInImage:image settings:s time:kCMTimeZero error:nil] count]==1 && retry.calls==2,@"failed detections remain retryable at the exact same frame");
|
||||
PGCountingDetector *bounded=[PGCountingDetector new];for(NSUInteger i=0;i<160;i++)[bounded cachedObservationsInImage:image settings:s time:CMTimeMake(i,25) error:nil];
|
||||
NSUInteger before=bounded.calls;[bounded cachedObservationsInImage:image settings:s time:CMTimeMake(159,25) error:nil];
|
||||
Check(bounded.calls==before,@"recent detection remains cached after a long authored sequence");
|
||||
[bounded cachedObservationsInImage:image settings:s time:kCMTimeZero error:nil];Check(bounded.calls==before+1,@"observation history is bounded and old frames can be recomputed");
|
||||
// Inject only the tracker output to exercise processImage's real held-mask
|
||||
// integration. The detector reports no person; all geometry is authored.
|
||||
PGCountingDetector *maskProcessor=[PGCountingDetector new];PGScriptedOutputTracker *held=[PGScriptedOutputTracker new];
|
||||
NSDictionary *g=PGTrackingTestGeometry(CGRectMake(.15,.12,.7,.78),0);held.scriptedOutput=@{@"output":@[g],@"held":@YES,@"index":@(-1)};
|
||||
[maskProcessor setValue:held forKey:@"realtime"];s=PGBeautyDefaults();s.faceThin=1;
|
||||
PGFaceFrame *frame=[maskProcessor processImage:image settings:s time:kCMTimeZero error:nil];
|
||||
CIContext *context=[CIContext contextWithOptions:nil];float colorPixel[4]={0},skinPixel[4]={0};CGRect cheek=CGRectMake(88,130,1,1);
|
||||
[context render:frame.colorMask toBitmap:colorPixel rowBytes:sizeof(colorPixel) bounds:cheek format:kCIFormatRGBAf colorSpace:NULL];
|
||||
[context render:frame.skinMask toBitmap:skinPixel rowBytes:sizeof(skinPixel) bounds:cheek format:kCIFormatRGBAf colorSpace:NULL];
|
||||
Check(frame.deformationGeometry.count==1 && colorPixel[0]>.5 && skinPixel[0]>.25,@"default held geometry preserves skin and color support through complete detection loss");
|
||||
Check(MaskPeak(context,frame.teethMask)>.5 && MaskPeak(context,frame.eyeMask)>.5,@"held default geometry retains nonzero teeth and eye support after the shared warp");
|
||||
PGCountingDetector *errorProcessor=[PGCountingDetector new];PGScriptedTracker *objectTracker=[PGScriptedTracker new];
|
||||
[errorProcessor setValue:objectTracker forKey:@"realtime"];errorProcessor.suppliedFaces=(NSArray<VNFaceObservation *> *)@[AuthoredObservation(g)];
|
||||
PGFaceFrame *valid=[errorProcessor processImage:image settings:s time:kCMTimeZero error:nil];
|
||||
Check(valid.deformationGeometry.count==1 && [valid.deformationGeometry[0][@"points76"] count]==76,@"public-selector authored observations seed actual processImage accepted geometry");
|
||||
errorProcessor.fail=YES;objectTracker.proposedBox=PGArrayRect(g[@"box"]);NSError *stageError=nil;
|
||||
PGFaceFrame *failureHold=[errorProcessor processImage:image settings:s time:CMTimeMake(1,25) error:&stageError];
|
||||
Check(failureHold!=nil && failureHold.deformationGeometry.count==1 && [failureHold.deformationGeometry[0][@"points76"] count]==76,@"a realtime detector API error returns a frame with the last accepted complete shape");
|
||||
Check(failureHold!=nil && MaskPeak(context,failureHold.skinMask)>.5 && MaskPeak(context,failureHold.colorMask)>.5 && MaskPeak(context,failureHold.teethMask)>.5 && MaskPeak(context,failureHold.eyeMask)>.5,@"realtime detector errors keep all held beauty masks instead of flashing them off");
|
||||
NSDictionary *recovered=PGTrackingTestGeometry(PGArrayRect(g[@"box"]),.01);
|
||||
errorProcessor.fail=NO;errorProcessor.suppliedFaces=(NSArray<VNFaceObservation *> *)@[AuthoredObservation(recovered)];
|
||||
PGFaceFrame *sameTime=[errorProcessor processImage:image settings:s time:CMTimeMake(1,25) error:nil];
|
||||
Check(EqualPoints(sameTime.deformationGeometry.firstObject[@"points76"],recovered[@"points76"],1e-12),@"same-time recovery replaces a transient error hold instead of replaying its frozen geometry");
|
||||
PGCountingDetector *coldError=[PGCountingDetector new];coldError.fail=YES;
|
||||
[coldError processImage:image settings:s time:kCMTimeZero error:nil];coldError.fail=NO;
|
||||
coldError.suppliedFaces=(NSArray<VNFaceObservation *> *)@[AuthoredObservation(g)];
|
||||
PGFaceFrame *acquired=[coldError processImage:image settings:s time:kCMTimeZero error:nil];
|
||||
Check(acquired.deformationGeometry.count==1&&coldError.calls==2,@"a cold API failure cannot freeze the exact frame without geometry after a successful retry");
|
||||
errorProcessor.fail=YES;
|
||||
[[errorProcessor valueForKey:@"observationCache"] removeAllObjects];
|
||||
PGFaceFrame *cachedFailure=[errorProcessor processImage:image settings:s time:kCMTimeZero error:nil];
|
||||
Check(cachedFailure.deformationGeometry.count==1&&MaskPeak(context,cachedFailure.skinMask)>.5&&MaskPeak(context,cachedFailure.colorMask)>.5&&MaskPeak(context,cachedFailure.eyeMask)>.5&&MaskPeak(context,cachedFailure.teethMask)>.5,@"an API error after detection-cache eviction preserves masks even when geometry hits an older successful snapshot");
|
||||
PGBeautySettings offline=s;offline.trackingEnabled=1;stageError=nil;
|
||||
Check([errorProcessor processImage:image settings:offline time:CMTimeMake(2,25) error:&stageError]==nil && stageError!=nil,@"explicit offline tracking still reports detector errors rather than silently holding live geometry");
|
||||
}
|
||||
static void VisionSequence(void){
|
||||
PGRealtimeFaceTracker *t=[PGRealtimeFaceTracker new];CGRect seed=CGRectMake(76./256,51./256,90./256,128./256);
|
||||
CGRect start=[t trackedBoxForImage:ObjectFrame(0) time:kCMTimeZero seedBox:seed];Check(PGValidRect(start),@"real sequence tracker accepts an authored textured object seed");
|
||||
for(NSUInteger i=1;i<=5;i++){
|
||||
CGRect tracked=[t trackedBoxForImage:ObjectFrame(i) time:CMTimeMake(i,25) seedBox:CGRectNull];
|
||||
Check(PGValidRect(tracked) && fabs(CGRectGetMidX(tracked)-(CGRectGetMidX(seed)+2.*i/256))<.07 && fabs(CGRectGetMidY(tracked)-CGRectGetMidY(seed))<.07,@"real sequence tracker follows a known synthetic translation without losing the box");
|
||||
CGRect again=[t trackedBoxForImage:ObjectFrame(i) time:CMTimeMake(i,25) seedBox:CGRectNull];Check(CGRectEqualToRect(tracked,again),@"real sequence same-frame reuse does not advance tracking twice");
|
||||
}
|
||||
CGRect rewind=[t trackedBoxForImage:ObjectFrame(3) time:CMTimeMake(3,25) seedBox:CGRectNull];
|
||||
Check(!PGValidRect(rewind),@"a real sequence rewind cannot return a future tracked box without a fresh seed");
|
||||
CGRect reseed=CGRectOffset(seed,6./256,0);CGRect restarted=[t trackedBoxForImage:ObjectFrame(3) time:CMTimeMake(3,25) seedBox:reseed];
|
||||
Check(CGRectEqualToRect(reseed,restarted),@"a rewind with an explicit box restarts the real Vision sequence at that seed");
|
||||
PGRealtimeFaceTracker *gap=[PGRealtimeFaceTracker new];
|
||||
[gap trackedBoxForImage:ObjectFrame(0) time:kCMTimeZero seedBox:seed];
|
||||
Check(!PGValidRect([gap trackedBoxForImage:ObjectFrame(8) time:CMTimeMake(8,25) seedBox:CGRectNull]),@"a gap over .2 seconds invalidates an unseeded continuous object request");
|
||||
Check(CGRectEqualToRect([gap trackedBoxForImage:ObjectFrame(9) time:CMTimeMake(9,25) seedBox:CGRectNull],seed),@"after gap invalidation the next unseeded frame follows the recovered short lost-box hold path");
|
||||
}
|
||||
int main(void){@autoreleasepool{QualityAndAcceptance();SequenceAndCache();DetectionCacheAndHeldMasks();VisionSequence();printf("%s: %lu tracking sequence checks (%lu failures); synthetic data only\n",failures?"FAIL":"PASS",(unsigned long)checks,(unsigned long)failures);return failures?1:0;}}
|
||||
在新工单中引用
屏蔽一个用户