diff --git a/beatygril/beatygril/Plugin/PGFaceProcessor.h b/beatygril/beatygril/Plugin/PGFaceProcessor.h index 01c3a35..0b86183 100644 --- a/beatygril/beatygril/Plugin/PGFaceProcessor.h +++ b/beatygril/beatygril/Plugin/PGFaceProcessor.h @@ -10,6 +10,10 @@ NS_ASSUME_NONNULL_BEGIN /// Vision observations in normalized, bottom-left image coordinates. Restored /// observations retain their bounding box; render geometry is stored separately. @property (nonatomic, copy, readonly) NSArray *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 *deformationGeometry; @property (nonatomic, strong, readonly) CIImage *skinMask; /// Continuous face support before eye/nose/lip detail-protection cutouts. @property (nonatomic, strong, readonly) CIImage *colorMask; @@ -31,7 +35,8 @@ NS_ASSUME_NONNULL_BEGIN error:(NSError * _Nullable * _Nullable)error; - (void)resetTracking; /// 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; - (void)setTrackingData:(nullable NSData *)data forTarget:(NSUInteger)target; @end diff --git a/beatygril/beatygril/Plugin/PGFaceProcessor.m b/beatygril/beatygril/Plugin/PGFaceProcessor.m index 5216715..69359f5 100644 --- a/beatygril/beatygril/Plugin/PGFaceProcessor.m +++ b/beatygril/beatygril/Plugin/PGFaceProcessor.m @@ -9,6 +9,7 @@ static NSString * const PGFaceErrorDomain = @"media.dayu.PaintFCP.Face"; @interface PGFaceFrame () @property (nonatomic, copy, readwrite) NSArray *faces; +@property (nonatomic, copy, readwrite) NSArray *deformationGeometry; @property (nonatomic, strong, readwrite) CIImage *skinMask; @property (nonatomic, strong, readwrite) CIImage *colorMask; @property (nonatomic, strong, readwrite) CIImage *teethMask; @@ -119,6 +120,151 @@ static CIImage *PGMaximum(CIImage *a, CIImage *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 *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 *_entries; + NSUInteger _estimatedBytes; +} +- (NSDictionary *)resultForGeometry:(NSArray *)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 *)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;iCGRectGetMaxX(guard)||center.yCGRectGetMaxY(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 () { NSLock *_lock; NSMutableArray *> *_targets; @@ -126,6 +272,7 @@ static CIImage *PGMaximum(CIImage *a, CIImage *b) { CIColorKernel *_ellipse; CIColorKernel *_subtract; CIColorKernel *_box; + PGRealtimeFaceTracker *_realtime; } @end @@ -135,6 +282,7 @@ static CIImage *PGMaximum(CIImage *a, CIImage *b) { self = [super init]; if (self) { _lock = [[NSLock alloc] init]; + _realtime = [PGRealtimeFaceTracker new]; _targets = [NSMutableArray arrayWithObjects:[NSMutableDictionary dictionary], [NSMutableDictionary dictionary], [NSMutableDictionary dictionary], nil]; // Analytic, feathered sampling deformation authored for this project. // Positive short-face shortens the lower face; positive chin lengthens it. @@ -292,29 +440,56 @@ static CIImage *PGMaximum(CIImage *a, CIImage *b) { return nil; } NSDictionary *record = settings.trackingEnabled ? [self recordForTime:time target:MIN((NSUInteger)settings.targetIndex, 2) nearest:NO] : nil; - NSArray *geometry; + NSArray *geometry,*appearance; NSArray *observations; if (record) { geometry = record[@"faces"]; + appearance = geometry; NSMutableArray *restored = [NSMutableArray array]; for (NSDictionary *face in geometry) [restored addObject:[VNFaceObservation faceObservationWithRequestRevision:VNDetectFaceLandmarksRequestRevision3 boundingBox:PGArrayRect(face[@"box"]) roll:nil yaw:nil pitch:nil]]; observations = restored; } else { NSArray *detected = [self observationsInImage:image settings:settings error:error]; if (!detected) return nil; - // A missing time may use a nearby analyzed identity as a guard, while - // still detecting the current image. Rendering never appends to history. - NSDictionary *anchor = settings.trackingEnabled ? [self recordForTime:time target:MIN((NSUInteger)settings.targetIndex, 2) nearest:YES] : nil; - geometry = [self geometryForObservations:detected settings:settings previous:anchor]; - NSMutableArray *selected = [NSMutableArray array]; - for (NSDictionary *face in geometry) { - CGRect box = PGArrayRect(face[@"box"]); - for (VNFaceObservation *observation in detected) if (CGRectEqualToRect(observation.boundingBox, box)) { [selected addObject:observation]; break; } + if(settings.trackingEnabled) { + // Preserve the explicit offline-analysis contract. Its stored + // 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]; + appearance=geometry; + NSMutableArray *selected=[NSMutableArray new]; + for(NSDictionary *face in geometry) for(VNFaceObservation *observation in detected) + if(CGRectEqualToRect(observation.boundingBox,PGArrayRect(face[@"box"]))) {[selected addObject:observation];break;} + 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 #import #import "PGFaceProcessor.h" +#import +#include + +// 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 *)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 // tested independently of the separate public-domain NASA portrait fixture. @@ -58,6 +68,185 @@ static CIImage *Gradient(void) { 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))