Implement and test portrait beauty, face geometry and persistent tracking
这个提交包含在:
1 父节点
e2803e727f
当前提交
0310ca83ba
15 个文件已更改
+1203
-42
没有与搜索条件相匹配的文件
@@ -63,10 +63,11 @@
|
|||||||
"/Localized: Plugin/InfoPlist.strings",
|
"/Localized: Plugin/InfoPlist.strings",
|
||||||
"/Localized: Plugin/Localizable.strings",
|
"/Localized: Plugin/Localizable.strings",
|
||||||
Plugin/beatygrilPlugIn.m,
|
Plugin/beatygrilPlugIn.m,
|
||||||
|
Plugin/PGBeautyRenderer.m,
|
||||||
|
Plugin/PGFaceProcessor.m,
|
||||||
|
Plugin/PGFaceSelectOSC.m,
|
||||||
Plugin/Info.plist,
|
Plugin/Info.plist,
|
||||||
Plugin/main.m,
|
Plugin/main.m,
|
||||||
Plugin/MetalDeviceCache.m,
|
|
||||||
Plugin/TileableRemoteBrightness.metal,
|
|
||||||
"Wrapper Application/Info.plist",
|
"Wrapper Application/Info.plist",
|
||||||
);
|
);
|
||||||
target = 6945FC8A3088B7220013E108 /* Wrapper Application */;
|
target = 6945FC8A3088B7220013E108 /* Wrapper Application */;
|
||||||
@@ -248,11 +249,12 @@
|
|||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
6945FC933088B7220013E108 /* Copy and Code Sign FxPlug.framework */ = {
|
6945FC933088B7220013E108 /* Copy and Code Sign FxPlug.framework */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
name = "Copy and Code Sign FxPlug.framework";
|
name = "Copy and Code Sign FxPlug.framework";
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = (
|
shellScript = (
|
||||||
"mkdir -p \"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}\"",
|
"mkdir -p \"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}\"",
|
||||||
"rsync --archive --links --whole-file --no-owner --no-group --checksum --exclude='Headers' \"/Library/Developer/SDKs/FxPlug.sdk/Library/Frameworks/FxPlug.framework/\" \"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FxPlug.framework/\"",
|
"rsync --archive --links --whole-file --no-owner --no-group --checksum --exclude='Headers' \"/Library/Developer/Frameworks/FxPlug.framework/\" \"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FxPlug.framework/\"",
|
||||||
"if [ \"$?\" -ne 0 ]; then echo \"error: rsync failed; couldn't embed FxPlug.framework\"; exit 1; fi",
|
"if [ \"$?\" -ne 0 ]; then echo \"error: rsync failed; couldn't embed FxPlug.framework\"; exit 1; fi",
|
||||||
"codesign --force --timestamp --options runtime --sign \"${CODE_SIGN_IDENTITY}\" \"${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/FxPlug.framework\"",
|
"codesign --force --timestamp --options runtime --sign \"${CODE_SIGN_IDENTITY}\" \"${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/FxPlug.framework\"",
|
||||||
);
|
);
|
||||||
@@ -260,11 +262,12 @@
|
|||||||
};
|
};
|
||||||
6945FC943088B7220013E108 /* Copy and Code Sign PluginManager.framework */ = {
|
6945FC943088B7220013E108 /* Copy and Code Sign PluginManager.framework */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
name = "Copy and Code Sign PluginManager.framework";
|
name = "Copy and Code Sign PluginManager.framework";
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = (
|
shellScript = (
|
||||||
"mkdir -p \"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}\"",
|
"mkdir -p \"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}\"",
|
||||||
"rsync --archive --links --whole-file --no-owner --no-group --checksum --exclude='Headers' \"/Library/Developer/SDKs/FxPlug.sdk/Library/Frameworks/PluginManager.framework/\" \"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/PluginManager.framework/\"",
|
"rsync --archive --links --whole-file --no-owner --no-group --checksum --exclude='Headers' \"/Library/Developer/Frameworks/PluginManager.framework/\" \"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/PluginManager.framework/\"",
|
||||||
"if [ \"$?\" -ne 0 ]; then echo \"error: rsync failed; couldn't embed PluginManager.framework\"; exit 1; fi",
|
"if [ \"$?\" -ne 0 ]; then echo \"error: rsync failed; couldn't embed PluginManager.framework\"; exit 1; fi",
|
||||||
"codesign --force --timestamp --options runtime --sign \"${CODE_SIGN_IDENTITY}\" \"${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/PluginManager.framework\"",
|
"codesign --force --timestamp --options runtime --sign \"${CODE_SIGN_IDENTITY}\" \"${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/PluginManager.framework\"",
|
||||||
"",
|
"",
|
||||||
@@ -304,6 +307,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO;
|
||||||
CODE_SIGN_IDENTITY = "Developer ID Application";
|
CODE_SIGN_IDENTITY = "Developer ID Application";
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
DEVELOPMENT_TEAM = B3W25FX5H5;
|
DEVELOPMENT_TEAM = B3W25FX5H5;
|
||||||
@@ -326,6 +330,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO;
|
||||||
CODE_SIGN_IDENTITY = "Developer ID Application";
|
CODE_SIGN_IDENTITY = "Developer ID Application";
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
DEVELOPMENT_TEAM = B3W25FX5H5;
|
DEVELOPMENT_TEAM = B3W25FX5H5;
|
||||||
@@ -349,6 +354,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = "beatygril/Wrapper Application/SandboxEntitlements.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "beatygril/Wrapper Application/SandboxEntitlements.entitlements";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO;
|
||||||
CODE_SIGN_IDENTITY = "Developer ID Application";
|
CODE_SIGN_IDENTITY = "Developer ID Application";
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
DEVELOPMENT_TEAM = B3W25FX5H5;
|
DEVELOPMENT_TEAM = B3W25FX5H5;
|
||||||
@@ -365,6 +371,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = "beatygril/Wrapper Application/SandboxEntitlements.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "beatygril/Wrapper Application/SandboxEntitlements.entitlements";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO;
|
||||||
CODE_SIGN_IDENTITY = "Developer ID Application";
|
CODE_SIGN_IDENTITY = "Developer ID Application";
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
DEVELOPMENT_TEAM = B3W25FX5H5;
|
DEVELOPMENT_TEAM = B3W25FX5H5;
|
||||||
@@ -380,6 +387,8 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ADDITIONAL_SDKS = /Library/Developer/SDKs/FxPlug.sdk;
|
ADDITIONAL_SDKS = /Library/Developer/SDKs/FxPlug.sdk;
|
||||||
|
ARCHS = "arm64 x86_64";
|
||||||
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
@@ -436,10 +445,9 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_VERSION = 5;
|
SWIFT_VERSION = 5;
|
||||||
};
|
};
|
||||||
@@ -449,6 +457,8 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ADDITIONAL_SDKS = /Library/Developer/SDKs/FxPlug.sdk;
|
ADDITIONAL_SDKS = /Library/Developer/SDKs/FxPlug.sdk;
|
||||||
|
ARCHS = "arm64 x86_64";
|
||||||
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
@@ -499,7 +509,7 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
|
|||||||
@@ -110,6 +110,6 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0</string>
|
<string>13.0</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#import <CoreImage/CoreImage.h>
|
||||||
|
#import "PGBeautyTypes.h"
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
/// Stateless, lazy Core Image graph. All masks use red=coverage and image coordinates.
|
||||||
|
/// Caller owns face detection, geometric warping, tracking, and the CIContext/Metal device.
|
||||||
|
/// showMask enables debugChannel 0=skin, 1=teeth, 2=eyes. invertMask affects each preview.
|
||||||
|
/// If the caller blends geometry plus beauty against the original, pass settings.mix=1.
|
||||||
|
@interface PGBeautyRenderer : NSObject
|
||||||
|
- (nullable CIImage *)processImage:(CIImage *)image
|
||||||
|
settings:(PGBeautySettings)settings
|
||||||
|
skinMask:(nullable CIImage *)skinMask
|
||||||
|
teethMask:(nullable CIImage *)teethMask
|
||||||
|
eyeMask:(nullable CIImage *)eyeMask
|
||||||
|
error:(NSError * _Nullable * _Nullable)error;
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
#import "PGBeautyRenderer.h"
|
||||||
|
#import <math.h>
|
||||||
|
|
||||||
|
// Original implementation reconstructed from observed processing stages, not vendor
|
||||||
|
// resources. The learned segmentation model and LUTs are deliberately not embedded.
|
||||||
|
static float PGClamp(float v, float lo, float hi) { return isfinite(v) ? fmaxf(lo, fminf(hi, v)) : lo; }
|
||||||
|
static CIImage *PGBlur(CIImage *image, CGFloat radius, CGRect extent) {
|
||||||
|
return [[[image imageByClampingToExtent] imageByApplyingFilter:@"CIGaussianBlur"
|
||||||
|
withInputParameters:@{kCIInputRadiusKey:@(radius)}] imageByCroppingToRect:extent];
|
||||||
|
}
|
||||||
|
static CIColorKernel *PGKernel(NSString *body) {
|
||||||
|
#pragma clang diagnostic push
|
||||||
|
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||||
|
return [CIColorKernel kernelWithString:body];
|
||||||
|
#pragma clang diagnostic pop
|
||||||
|
}
|
||||||
|
static CIImage *PGApply(CIColorKernel *kernel, CGRect extent, NSArray *arguments) {
|
||||||
|
return [kernel applyWithExtent:extent arguments:arguments];
|
||||||
|
}
|
||||||
|
static NSDictionary<NSString *, CIColorKernel *> *PGKernels(void) {
|
||||||
|
static NSDictionary *kernels;
|
||||||
|
static dispatch_once_t once;
|
||||||
|
dispatch_once(&once, ^{
|
||||||
|
NSMutableDictionary *k = [NSMutableDictionary dictionary];
|
||||||
|
// The fallback mask observes the shipped G/B overlay > Gaussian high-pass >
|
||||||
|
// repeated hard-light stages. Semantic skin support is independently estimated.
|
||||||
|
k[@"overlay"] = PGKernel(@
|
||||||
|
"kernel vec4 pgGB(__sample p) { vec4 q=unpremultiply(p);"
|
||||||
|
"float g=clamp(q.g*0.5,0.0,1.0); float b=clamp(q.b*0.5,0.0,1.0);"
|
||||||
|
"float v=2.0*g*b; return vec4(v,v,v,1.0); }");
|
||||||
|
k[@"candidate"] = PGKernel(@
|
||||||
|
"kernel vec4 pgCandidate(__sample p,__sample g,__sample b) {"
|
||||||
|
"vec3 c=clamp(unpremultiply(p).rgb,0.0,1.0);"
|
||||||
|
"float y=dot(c,vec3(0.299,0.587,0.114));"
|
||||||
|
"float cb=(c.b-y)*0.564+0.5; float cr=(c.r-y)*0.713+0.5;"
|
||||||
|
"float chroma=exp(-pow((cb-0.415)/0.115,2.0)-pow((cr-0.565)/0.135,2.0));"
|
||||||
|
"float h=clamp(g.r-b.r+0.5,0.0,1.0);"
|
||||||
|
"h=mix(2.0*h*h,1.0-2.0*(1.0-h)*(1.0-h),step(0.5,h));"
|
||||||
|
"h=mix(2.0*h*h,1.0-2.0*(1.0-h)*(1.0-h),step(0.5,h));"
|
||||||
|
"h=mix(2.0*h*h,1.0-2.0*(1.0-h)*(1.0-h),step(0.5,h));"
|
||||||
|
"float band=clamp((h-0.29411765)*2.86516854,0.0,1.0);"
|
||||||
|
"float edges=1.0-smoothstep(0.06,0.26,abs(band-0.59));"
|
||||||
|
"float value=clamp(chroma*smoothstep(0.025,0.15,y)*(0.8+0.2*edges),0.0,1.0);"
|
||||||
|
"return vec4(value,value,value,1.0); }");
|
||||||
|
k[@"mask"] = PGKernel(@
|
||||||
|
"kernel vec4 pgMask(__sample raw,__sample feather,__sample p,__sample b,__sample eyes,__sample teeth,"
|
||||||
|
"float threshold,float softness,float edge,float protection,float invert) {"
|
||||||
|
"float m=clamp(mix(raw.r,feather.r,softness),0.0,1.0);"
|
||||||
|
"float width=0.025+0.24*softness;"
|
||||||
|
"m=smoothstep(max(0.0,threshold-width),min(1.0,threshold+width),m);"
|
||||||
|
"m=mix(m,1.0-m,invert);"
|
||||||
|
"vec3 c=unpremultiply(p).rgb; vec3 l=unpremultiply(b).rgb;"
|
||||||
|
"float structure=length(c-l);"
|
||||||
|
"m*=1.0-edge*smoothstep(0.025,0.19,structure);"
|
||||||
|
"float features=clamp(max(eyes.r,teeth.r),0.0,1.0);"
|
||||||
|
"m*=1.0-protection*features;"
|
||||||
|
"return vec4(m,m,m,1.0); }");
|
||||||
|
// Independent frequency separation: texture and structure bands are compressed
|
||||||
|
// in luminance, with feature/edge attenuation, keeping chroma and alpha stable.
|
||||||
|
k[@"detail"] = PGKernel(@
|
||||||
|
"kernel vec4 pgDetail(__sample p,__sample fine,__sample broad,float strength,float protect) {"
|
||||||
|
"vec4 src=unpremultiply(p); vec3 c=src.rgb;"
|
||||||
|
"vec3 t=unpremultiply(fine).rgb; vec3 b=unpremultiply(broad).rgb;"
|
||||||
|
"vec3 w=vec3(0.299,0.587,0.114);"
|
||||||
|
"float y=dot(c,w); float yf=dot(t,w); float yb=dot(b,w);"
|
||||||
|
"float f=y-yf; float mid=yf-yb; float structure=length(c-b);"
|
||||||
|
"float middle=smoothstep(0.04,0.22,y)*(1.0-smoothstep(0.82,0.98,y));"
|
||||||
|
"float texture=smoothstep(0.001,0.010,abs(f));"
|
||||||
|
"float edge=1.0-smoothstep(0.08,0.26,structure);"
|
||||||
|
"float dark=(1.0-smoothstep(0.16,0.42,y))*smoothstep(0.0015,0.018,abs(f)+0.35*abs(y-yb));"
|
||||||
|
"float light=smoothstep(0.70,0.96,y)*smoothstep(0.0025,0.024,abs(f));"
|
||||||
|
"float ridge=smoothstep(0.10,0.30,structure)*smoothstep(0.002,0.020,abs(f)+0.85*abs(y-yb));"
|
||||||
|
"float feature=max(dark*0.96,max(light*0.52,ridge*0.84));"
|
||||||
|
"float gain=clamp(strength*middle*mix(0.56,0.88,texture),0.0,1.05);"
|
||||||
|
"gain*=1.0-protect*feature*mix(0.42,0.82,texture);"
|
||||||
|
"float delta=-gain*(f*mix(0.18,0.76,edge)+mid*mix(0.08,0.30,edge));"
|
||||||
|
"float target=clamp(y+delta,0.0,1.0); float ratio=clamp(target/max(y,0.001),0.82,1.18);"
|
||||||
|
"vec3 outColor=c*mix(1.0,ratio,0.88);"
|
||||||
|
"outColor+=vec3((target-dot(outColor,w))*0.08);"
|
||||||
|
"return premultiply(vec4(clamp(outColor,0.0,1.0),src.a)); }");
|
||||||
|
k[@"maskedMix"] = PGKernel(@
|
||||||
|
"kernel vec4 pgMaskedMix(__sample source,__sample effect,__sample mask,float amount) {"
|
||||||
|
"vec4 s=unpremultiply(source); vec4 e=unpremultiply(effect);"
|
||||||
|
"return premultiply(vec4(mix(s.rgb,e.rgb,clamp(mask.r*amount,0.0,1.0)),s.a)); }");
|
||||||
|
k[@"even"] = PGKernel(@
|
||||||
|
"kernel vec4 pgEven(__sample source,__sample broad,float amount) {"
|
||||||
|
"vec4 s=unpremultiply(source); vec3 b=unpremultiply(broad).rgb;"
|
||||||
|
"vec3 w=vec3(0.299,0.587,0.114); float y=dot(s.rgb,w); float yb=dot(b,w);"
|
||||||
|
"vec3 baseChroma=s.rgb-vec3(y); vec3 smoothChroma=b-vec3(yb);"
|
||||||
|
"float guard=1.0-smoothstep(0.12,0.35,length(s.rgb-b));"
|
||||||
|
"vec3 c=vec3(y)+mix(baseChroma,smoothChroma,amount*0.78*guard);"
|
||||||
|
"return premultiply(vec4(clamp(c,0.0,1.0),s.a)); }");
|
||||||
|
// These six looks are newly authored color transforms, not the shipped LUTs.
|
||||||
|
k[@"preset"] = PGKernel(@
|
||||||
|
"kernel vec4 pgPreset(__sample source,float preset) {"
|
||||||
|
"vec4 s=unpremultiply(source); vec3 c=s.rgb; float y=dot(c,vec3(0.299,0.587,0.114));"
|
||||||
|
"if(preset<1.5){c=mix(vec3(y),c,0.91);c+=vec3(0.038,0.027,0.012)*(1.0-y);}"
|
||||||
|
"else if(preset<2.5){c=mix(vec3(y),c,0.89);c+=vec3(0.003,0.020,0.038)*(1.0-y);}"
|
||||||
|
"else if(preset<3.5){c=mix(vec3(y),c,1.04);c+=vec3(0.037,0.011,-0.018)*(1.0-y);}"
|
||||||
|
"else if(preset<4.5){c=mix(vec3(y),c,0.94);c+=vec3(0.032,0.001,0.018)*(1.0-y);}"
|
||||||
|
"else if(preset<5.5){c=pow(max(c,vec3(0.0)),vec3(1.08));c+=vec3(0.024,0.003,-0.018)*(1.0-y);}"
|
||||||
|
"else{c=pow(max(c,vec3(0.0)),vec3(1.20));c=mix(vec3(dot(c,vec3(0.299,0.587,0.114))),c,1.06);"
|
||||||
|
"c+=vec3(0.030,0.007,-0.022)*(1.0-y);}"
|
||||||
|
"return premultiply(vec4(clamp(c,0.0,1.0),s.a)); }");
|
||||||
|
k[@"teeth"] = PGKernel(@
|
||||||
|
"kernel vec4 pgTeeth(__sample source,__sample mask,float amount) {"
|
||||||
|
"vec4 s=unpremultiply(source);vec3 c=clamp(s.rgb,0.0,1.0);"
|
||||||
|
"float y=dot(c,vec3(0.299,0.587,0.114));float hi=max(c.r,max(c.g,c.b));float lo=min(c.r,min(c.g,c.b));"
|
||||||
|
"float sat=(hi-lo)/max(hi,0.0001);"
|
||||||
|
"float gate=smoothstep(0.26,0.56,max(y,hi*0.86))*(1.0-smoothstep(0.52,0.86,sat));"
|
||||||
|
"float weight=clamp(pow(max(0.0,smoothstep(0.08,0.92,mask.r)*gate*amount),0.90)*(1.02+0.18*amount),0.0,0.88);"
|
||||||
|
"float yellow=max(0.0,(c.r+c.g)*0.5-c.b);"
|
||||||
|
"vec3 neutral=mix(c,vec3(y),0.16+0.22*amount);"
|
||||||
|
"neutral+=yellow*vec3(-0.070*amount,-0.028*amount,0.18+0.28*amount);"
|
||||||
|
"neutral+=(vec3(1.0)-neutral)*(0.045+0.155*amount);"
|
||||||
|
"return premultiply(vec4(mix(c,clamp(neutral,0.0,1.0),weight),s.a)); }");
|
||||||
|
k[@"eyes"] = PGKernel(@
|
||||||
|
"kernel vec4 pgEyes(__sample source,__sample mask,float amount) {"
|
||||||
|
"vec4 s=unpremultiply(source);float y=dot(s.rgb,vec3(0.299,0.587,0.114));"
|
||||||
|
"float weight=clamp(mask.r,0.0,1.0)*amount*smoothstep(0.08,0.32,y);"
|
||||||
|
"vec3 c=s.rgb+(vec3(1.0)-s.rgb)*(0.18*weight);"
|
||||||
|
"return premultiply(vec4(clamp(c,0.0,1.0),s.a)); }");
|
||||||
|
k[@"debug"] = PGKernel(@
|
||||||
|
"kernel vec4 pgDebug(__sample source,__sample mask,float invert) {"
|
||||||
|
"vec4 s=unpremultiply(source);float m=clamp(mask.r,0.0,1.0);"
|
||||||
|
"m=mix(m,1.0-m,invert);return premultiply(vec4(vec3(m),s.a)); }");
|
||||||
|
kernels = [k copy];
|
||||||
|
});
|
||||||
|
return kernels;
|
||||||
|
}
|
||||||
|
|
||||||
|
@implementation PGBeautyRenderer
|
||||||
|
- (CIImage *)processImage:(CIImage *)image settings:(PGBeautySettings)s
|
||||||
|
skinMask:(CIImage *)skinMask teethMask:(CIImage *)teethMask
|
||||||
|
eyeMask:(CIImage *)eyeMask error:(NSError **)error {
|
||||||
|
if (error) *error = nil;
|
||||||
|
CGRect extent = image.extent;
|
||||||
|
if (!image || CGRectIsEmpty(extent) || CGRectIsInfinite(extent) ||
|
||||||
|
!isfinite(extent.origin.x) || !isfinite(extent.origin.y) ||
|
||||||
|
!isfinite(extent.size.width) || !isfinite(extent.size.height)) {
|
||||||
|
if (error) *error = [NSError errorWithDomain:@"PGBeautyRenderer" code:1
|
||||||
|
userInfo:@{NSLocalizedDescriptionKey:@"The source image needs a finite nonempty extent."}];
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
float mix = PGClamp(s.mix, 0, 1);
|
||||||
|
if (mix == 0 && !s.showMask) return image;
|
||||||
|
NSDictionary *k = PGKernels();
|
||||||
|
if (k.count != 10) {
|
||||||
|
if (error) *error = [NSError errorWithDomain:@"PGBeautyRenderer" code:2
|
||||||
|
userInfo:@{NSLocalizedDescriptionKey:@"The beauty image kernels could not be compiled."}];
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
@try {
|
||||||
|
float amount = PGClamp(s.amount,0,1);
|
||||||
|
float local = PGClamp(s.midtoneDetail,0,2)*0.5f;
|
||||||
|
float global = PGClamp(s.highPassDetail,0,1);
|
||||||
|
float brightness = PGClamp(s.skinBrightness,0,1);
|
||||||
|
float hue = PGClamp(s.skinHue,-30,30);
|
||||||
|
float saturation = PGClamp(s.skinSaturation,-100,100);
|
||||||
|
float even = PGClamp(s.skinEvenTone,0,1);
|
||||||
|
float sharpness = PGClamp(s.sharpness,0,2);
|
||||||
|
float teeth = PGClamp(s.teethWhitening,0,1);
|
||||||
|
float eyes = PGClamp(s.eyeBrightening,0,1);
|
||||||
|
BOOL needsSkin = local*amount>0 || brightness>0 || hue!=0 || saturation!=0 || even>0 ||
|
||||||
|
(s.skinTonePreset>0 && s.skinTonePreset<=6) || (s.showMask && s.debugChannel==0);
|
||||||
|
CIImage *black = [[CIImage imageWithColor:[CIColor colorWithRed:0 green:0 blue:0 alpha:1]] imageByCroppingToRect:extent];
|
||||||
|
CIImage *teethInput = teethMask ? [teethMask imageByCroppingToRect:extent] : black;
|
||||||
|
CIImage *eyeInput = eyeMask ? [eyeMask imageByCroppingToRect:extent] : black;
|
||||||
|
CIImage *raw = skinMask ? [skinMask imageByCroppingToRect:extent] : black;
|
||||||
|
CIImage *mask = black;
|
||||||
|
CGFloat radius = PGClamp(s.radius,1,40);
|
||||||
|
if (needsSkin) {
|
||||||
|
if (!skinMask) {
|
||||||
|
CIImage *gb = PGApply(k[@"overlay"],extent,@[image]);
|
||||||
|
raw = PGApply(k[@"candidate"],extent,@[image,gb,PGBlur(gb,radius,extent)]);
|
||||||
|
}
|
||||||
|
float soft = PGClamp(s.maskSoftness,0,1);
|
||||||
|
float threshold = PGClamp(s.maskThreshold,0,255)/255.f;
|
||||||
|
mask = PGApply(k[@"mask"],extent,@[raw,PGBlur(raw,0.35+soft*radius*0.35,extent),
|
||||||
|
image,PGBlur(image,1.5,extent),eyeInput,teethInput,
|
||||||
|
@(threshold),@(soft),@(PGClamp(s.maskEdgeProtect,0,1)),
|
||||||
|
@(s.featureProtection ? 1 : 0),@(s.invertMask ? 1 : 0)]);
|
||||||
|
}
|
||||||
|
if (s.showMask) {
|
||||||
|
// Popup values select coverage source; changing the popup alone must
|
||||||
|
// never enable a preview. Feature masks retain their supplied coverage.
|
||||||
|
CIImage *preview = s.debugChannel==1 ? teethInput : (s.debugChannel==2 ? eyeInput : mask);
|
||||||
|
float invert = s.debugChannel==1 || s.debugChannel==2 ? (s.invertMask ? 1 : 0) : 0;
|
||||||
|
return PGApply(k[@"debug"],extent,@[image,preview,@(invert)]);
|
||||||
|
}
|
||||||
|
CIImage *result = image;
|
||||||
|
if (local*amount>0) {
|
||||||
|
// The shipped helper derives both blur scales from the detail strength.
|
||||||
|
CGFloat fine = fmin(7.4,2.275+3.15*pow(local,0.72)+0.95*local);
|
||||||
|
CGFloat broad = fmin(16,(2.55+0.45*local)*fine+1.1+1.3*local);
|
||||||
|
float compression = 0.12+2.4*powf(local,2.15)+1.25*local;
|
||||||
|
CIImage *detail = PGApply(k[@"detail"],extent,@[result,PGBlur(result,fine,extent),
|
||||||
|
PGBlur(result,broad,extent),@(compression),@(s.featureProtection ? 1 : 0)]);
|
||||||
|
result = PGApply(k[@"maskedMix"],extent,@[result,detail,mask,@(amount)]);
|
||||||
|
}
|
||||||
|
CIImage *color = result;
|
||||||
|
if (brightness>0) {
|
||||||
|
float shoulder = PGClamp((brightness-0.56f)/0.44f,0,1);
|
||||||
|
float gain = 0.88f*powf(brightness,1.24f)+0.12f*shoulder*shoulder*(3-2*shoulder);
|
||||||
|
color = [color imageByApplyingFilter:@"CIExposureAdjust" withInputParameters:@{kCIInputEVKey:@(gain*0.95f)}];
|
||||||
|
}
|
||||||
|
if (hue!=0) color = [color imageByApplyingFilter:@"CIHueAdjust" withInputParameters:@{kCIInputAngleKey:@(hue*M_PI/180.0)}];
|
||||||
|
if (saturation!=0) color = [color imageByApplyingFilter:@"CIColorControls" withInputParameters:@{kCIInputSaturationKey:@(1+saturation/100.f)}];
|
||||||
|
if (even>0) color = PGApply(k[@"even"],extent,@[color,PGBlur(color,radius*1.8,extent),@(even)]);
|
||||||
|
if (color!=result) result = PGApply(k[@"maskedMix"],extent,@[result,color,mask,@1]);
|
||||||
|
if (sharpness*amount>0) result = [result imageByApplyingFilter:@"CISharpenLuminance"
|
||||||
|
withInputParameters:@{kCIInputSharpnessKey:@(sharpness*amount)}];
|
||||||
|
if (s.skinTonePreset>0 && s.skinTonePreset<=6) {
|
||||||
|
CIImage *preset = PGApply(k[@"preset"],extent,@[result,@(s.skinTonePreset)]);
|
||||||
|
result = PGApply(k[@"maskedMix"],extent,@[result,preset,mask,@1]);
|
||||||
|
}
|
||||||
|
if (global>0) {
|
||||||
|
CGFloat fine = fmin(7.4,2.275+3.15*pow(global,0.72)+0.95*global);
|
||||||
|
CGFloat broad = fmin(16,(2.55+0.45*global)*fine+1.1+1.3*global);
|
||||||
|
float compression = 0.12+2.4*powf(global,2.15)+1.25*global;
|
||||||
|
result = PGApply(k[@"detail"],extent,@[result,PGBlur(result,fine,extent),
|
||||||
|
PGBlur(result,broad,extent),@(compression),@0]);
|
||||||
|
}
|
||||||
|
if (teeth>0 && teethMask) result = PGApply(k[@"teeth"],extent,@[result,teethInput,@(teeth)]);
|
||||||
|
if (eyes>0 && eyeMask) result = PGApply(k[@"eyes"],extent,@[result,eyeInput,@(eyes)]);
|
||||||
|
if (mix<1) {
|
||||||
|
CIImage *white = [[CIImage imageWithColor:[CIColor colorWithRed:1 green:1 blue:1 alpha:1]] imageByCroppingToRect:extent];
|
||||||
|
result = PGApply(k[@"maskedMix"],extent,@[image,result,white,@(mix)]);
|
||||||
|
}
|
||||||
|
if (!result) {
|
||||||
|
if (error) *error = [NSError errorWithDomain:@"PGBeautyRenderer" code:3
|
||||||
|
userInfo:@{NSLocalizedDescriptionKey:@"Core Image could not construct the beauty render graph."}];
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
return [result imageByCroppingToRect:extent];
|
||||||
|
} @catch (NSException *exception) {
|
||||||
|
if (error) *error = [NSError errorWithDomain:@"PGBeautyRenderer" code:4
|
||||||
|
userInfo:@{NSLocalizedDescriptionKey:exception.reason ?: @"Invalid Core Image render state."}];
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@end
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <CoreImage/CoreImage.h>
|
||||||
|
#import <CoreMedia/CoreMedia.h>
|
||||||
|
#import <Vision/Vision.h>
|
||||||
|
#import "PGBeautyTypes.h"
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@interface PGFaceFrame : NSObject
|
||||||
|
/// Vision observations in normalized, bottom-left image coordinates. Restored
|
||||||
|
/// observations retain their bounding box; render geometry is stored separately.
|
||||||
|
@property (nonatomic, copy, readonly) NSArray<VNFaceObservation *> *faces;
|
||||||
|
@property (nonatomic, strong, readonly) CIImage *skinMask;
|
||||||
|
@property (nonatomic, strong, readonly) CIImage *teethMask;
|
||||||
|
@property (nonatomic, strong, readonly) CIImage *eyeMask;
|
||||||
|
@property (nonatomic, strong, readonly) CIImage *image;
|
||||||
|
/// Transparent face/target outlines, for compositing after beauty processing.
|
||||||
|
@property (nonatomic, strong, readonly) CIImage *overlayImage;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface PGFaceProcessor : NSObject
|
||||||
|
- (nullable PGFaceFrame *)processImage:(CIImage *)image
|
||||||
|
settings:(PGBeautySettings)settings
|
||||||
|
time:(CMTime)time
|
||||||
|
error:(NSError * _Nullable * _Nullable)error;
|
||||||
|
- (BOOL)analyzeImage:(CIImage *)image
|
||||||
|
settings:(PGBeautySettings)settings
|
||||||
|
time:(CMTime)time
|
||||||
|
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.
|
||||||
|
- (nullable NSData *)trackingDataForTarget:(NSUInteger)target;
|
||||||
|
- (void)setTrackingData:(nullable NSData *)data forTarget:(NSUInteger)target;
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
// The source kernels remain available on the supported macOS 12+ range. This
|
||||||
|
// keeps the processor self contained instead of requiring a separate metallib.
|
||||||
|
#define CI_SILENCE_GL_DEPRECATION 1
|
||||||
|
#import "PGFaceProcessor.h"
|
||||||
|
#include <math.h>
|
||||||
|
#include <float.h>
|
||||||
|
|
||||||
|
static NSString * const PGFaceErrorDomain = @"media.dayu.PaintFCP.Face";
|
||||||
|
|
||||||
|
@interface PGFaceFrame ()
|
||||||
|
@property (nonatomic, copy, readwrite) NSArray<VNFaceObservation *> *faces;
|
||||||
|
@property (nonatomic, strong, readwrite) CIImage *skinMask;
|
||||||
|
@property (nonatomic, strong, readwrite) CIImage *teethMask;
|
||||||
|
@property (nonatomic, strong, readwrite) CIImage *eyeMask;
|
||||||
|
@property (nonatomic, strong, readwrite) CIImage *image;
|
||||||
|
@property (nonatomic, strong, readwrite) CIImage *overlayImage;
|
||||||
|
@end
|
||||||
|
@implementation PGFaceFrame
|
||||||
|
@end
|
||||||
|
|
||||||
|
// All geometry in stored records is normalized to the full source image, with
|
||||||
|
// Y increasing upward. Pixel size, render scale and tile order are not persisted.
|
||||||
|
static NSString *PGTimeKey(CMTime time) {
|
||||||
|
if (!CMTIME_IS_NUMERIC(time) || time.timescale <= 0) return nil;
|
||||||
|
int64_t a = time.value, b = time.timescale;
|
||||||
|
// Unsigned magnitude avoids overflow for the minimum signed integer.
|
||||||
|
uint64_t u = a < 0 ? (uint64_t)(-(a + 1)) + 1 : (uint64_t)a;
|
||||||
|
uint64_t v = (uint64_t)b;
|
||||||
|
while (v) { uint64_t r = u % v; u = v; v = r; }
|
||||||
|
int64_t divisor = (int64_t)(u ?: 1);
|
||||||
|
return [NSString stringWithFormat:@"%lld/%lld/%lld", (long long)(a / divisor),
|
||||||
|
(long long)(b / divisor), (long long)time.epoch];
|
||||||
|
}
|
||||||
|
|
||||||
|
static float PGClamp(float value, float low, float high) {
|
||||||
|
return isfinite(value) ? fminf(high, fmaxf(low, value)) : 0;
|
||||||
|
}
|
||||||
|
static NSArray *PGRectArray(CGRect rect) {
|
||||||
|
return @[@(rect.origin.x), @(rect.origin.y), @(rect.size.width), @(rect.size.height)];
|
||||||
|
}
|
||||||
|
static CGRect PGArrayRect(NSArray *array) {
|
||||||
|
if (![array isKindOfClass:NSArray.class] || array.count != 4) return CGRectZero;
|
||||||
|
for (id value in array) if (![value isKindOfClass:NSNumber.class] || !isfinite([value doubleValue])) return CGRectZero;
|
||||||
|
return CGRectMake([array[0] doubleValue], [array[1] doubleValue], [array[2] doubleValue], [array[3] doubleValue]);
|
||||||
|
}
|
||||||
|
static BOOL PGValidRect(CGRect rect) {
|
||||||
|
return rect.size.width > 0.001 && rect.size.height > 0.001 &&
|
||||||
|
rect.origin.x >= -0.1 && rect.origin.y >= -0.1 && CGRectGetMaxX(rect) <= 1.1 && CGRectGetMaxY(rect) <= 1.1;
|
||||||
|
}
|
||||||
|
static CGPoint PGCenter(CGRect rect) { return CGPointMake(CGRectGetMidX(rect), CGRectGetMidY(rect)); }
|
||||||
|
static NSArray *PGRegion(VNFaceLandmarkRegion2D *region, CGRect box) {
|
||||||
|
if (!region || region.pointCount == 0) return @[];
|
||||||
|
NSMutableArray *points = [NSMutableArray arrayWithCapacity:region.pointCount];
|
||||||
|
const CGPoint *p = region.normalizedPoints;
|
||||||
|
if (!p) return @[];
|
||||||
|
for (NSUInteger i = 0; i < MIN(region.pointCount, 256); i++) {
|
||||||
|
if (isfinite(p[i].x) && isfinite(p[i].y)) {
|
||||||
|
[points addObject:@[@(box.origin.x + p[i].x * box.size.width),
|
||||||
|
@(box.origin.y + p[i].y * box.size.height)]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
static NSDictionary *PGGeometry(VNFaceObservation *observation) {
|
||||||
|
CGRect box = observation.boundingBox;
|
||||||
|
VNFaceLandmarks2D *lm = observation.landmarks;
|
||||||
|
return @{@"box": PGRectArray(box),
|
||||||
|
@"leftEye": PGRegion(lm.leftEye, box), @"rightEye": PGRegion(lm.rightEye, box),
|
||||||
|
@"leftBrow": PGRegion(lm.leftEyebrow, box), @"rightBrow": PGRegion(lm.rightEyebrow, box),
|
||||||
|
@"nose": PGRegion(lm.nose, box), @"lips": PGRegion(lm.outerLips, box),
|
||||||
|
@"mouth": PGRegion(lm.innerLips, box), @"contour": PGRegion(lm.faceContour, box)};
|
||||||
|
}
|
||||||
|
static NSDictionary *PGSanitizedGeometry(id value) {
|
||||||
|
if (![value isKindOfClass:NSDictionary.class]) return nil;
|
||||||
|
CGRect box = PGArrayRect(value[@"box"]);
|
||||||
|
if (!PGValidRect(box)) return nil;
|
||||||
|
NSMutableDictionary *result = [NSMutableDictionary dictionaryWithObject:PGRectArray(box) forKey:@"box"];
|
||||||
|
for (NSString *name in @[@"leftEye", @"rightEye", @"leftBrow", @"rightBrow", @"nose", @"lips", @"mouth", @"contour"]) {
|
||||||
|
id raw = value[name];
|
||||||
|
if (![raw isKindOfClass:NSArray.class] || [raw count] > 256) return nil;
|
||||||
|
NSMutableArray *points = [NSMutableArray array];
|
||||||
|
for (id point in raw) {
|
||||||
|
if (![point isKindOfClass:NSArray.class] || [point count] != 2) return nil;
|
||||||
|
for (id coordinate in point) if (![coordinate isKindOfClass:NSNumber.class] || !isfinite([coordinate doubleValue]) || fabs([coordinate doubleValue]) > 2) return nil;
|
||||||
|
[points addObject:[point copy]];
|
||||||
|
}
|
||||||
|
result[name] = points;
|
||||||
|
}
|
||||||
|
return [result copy];
|
||||||
|
}
|
||||||
|
static CGRect PGRegionBounds(NSDictionary *geometry, NSString *name, CGRect fallback) {
|
||||||
|
NSArray *points = geometry[name];
|
||||||
|
if (points.count == 0) return fallback;
|
||||||
|
CGFloat minX = 2, minY = 2, maxX = -1, maxY = -1;
|
||||||
|
for (NSArray *point in points) {
|
||||||
|
CGFloat x = [point[0] doubleValue], y = [point[1] doubleValue];
|
||||||
|
minX = MIN(minX, x); minY = MIN(minY, y); maxX = MAX(maxX, x); maxY = MAX(maxY, y);
|
||||||
|
}
|
||||||
|
return CGRectMake(minX, minY, MAX(0.001, maxX - minX), MAX(0.001, maxY - minY));
|
||||||
|
}
|
||||||
|
static CGRect PGInFace(CGRect face, CGFloat x, CGFloat y, CGFloat w, CGFloat h) {
|
||||||
|
return CGRectMake(face.origin.x + x * face.size.width, face.origin.y + y * face.size.height,
|
||||||
|
w * face.size.width, h * face.size.height);
|
||||||
|
}
|
||||||
|
static CGRect PGToPixels(CGRect normalized, CGRect extent) {
|
||||||
|
return CGRectMake(extent.origin.x + normalized.origin.x * extent.size.width,
|
||||||
|
extent.origin.y + normalized.origin.y * extent.size.height,
|
||||||
|
normalized.size.width * extent.size.width, normalized.size.height * extent.size.height);
|
||||||
|
}
|
||||||
|
static CIVector *PGVector(CGPoint point) { return [CIVector vectorWithX:point.x Y:point.y]; }
|
||||||
|
static CIImage *PGBlack(CGRect extent) {
|
||||||
|
return [[CIImage imageWithColor:[CIColor colorWithRed:0 green:0 blue:0 alpha:1]] imageByCroppingToRect:extent];
|
||||||
|
}
|
||||||
|
static CIImage *PGTransparent(CGRect extent) {
|
||||||
|
return [[CIImage imageWithColor:[CIColor colorWithRed:0 green:0 blue:0 alpha:0]] imageByCroppingToRect:extent];
|
||||||
|
}
|
||||||
|
static CIImage *PGMaximum(CIImage *a, CIImage *b) {
|
||||||
|
return [a imageByApplyingFilter:@"CIMaximumCompositing" withInputParameters:@{kCIInputBackgroundImageKey:b}];
|
||||||
|
}
|
||||||
|
|
||||||
|
@interface PGFaceProcessor () {
|
||||||
|
NSLock *_lock;
|
||||||
|
NSMutableArray<NSMutableDictionary<NSString *, NSDictionary *> *> *_targets;
|
||||||
|
CIWarpKernel *_warp;
|
||||||
|
CIColorKernel *_ellipse;
|
||||||
|
CIColorKernel *_subtract;
|
||||||
|
CIColorKernel *_box;
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation PGFaceProcessor
|
||||||
|
|
||||||
|
- (instancetype)init {
|
||||||
|
self = [super init];
|
||||||
|
if (self) {
|
||||||
|
_lock = [[NSLock alloc] init];
|
||||||
|
_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.
|
||||||
|
// This is an independent approximation, not the reference vendor's warp.
|
||||||
|
NSString *source = @"kernel vec2 pgFaceWarp(vec2 c, vec2 size, vec2 le, vec2 re, vec2 er, float thin, float bigEye, float shortFace, float narrow, float chin, float vFace, float jaw, float cheek, float smallHead, float headRange) {"
|
||||||
|
" vec2 p = destCoord(); vec2 n = (p-c)/size;"
|
||||||
|
" float body = 1.0-smoothstep(0.38,0.76,length(n*vec2(1.0,0.82)));"
|
||||||
|
" float lower = (1.0-smoothstep(-0.12,0.27,n.y))*body;"
|
||||||
|
" float sides = smoothstep(0.08,0.32,abs(n.x));"
|
||||||
|
" float j=(n.y+0.23)/0.22; float jawBand = exp(-j*j)*body;"
|
||||||
|
" float ch=(n.y-0.02)/0.19; float cheekBand = exp(-ch*ch)*body;"
|
||||||
|
" float head = 1.0-smoothstep(0.38,0.75+0.32*headRange,length(n*vec2(0.9,0.65)));"
|
||||||
|
" p += (p-c)*smallHead*0.18*head;"
|
||||||
|
" p.x += size.x*n.x*(narrow*0.30*body + thin*0.26*lower + vFace*0.24*jawBand + jaw*0.20*jawBand + cheek*0.18*cheekBand)*sides;"
|
||||||
|
" float cn=n.x/0.30; p.y += size.y*(-shortFace*0.16*lower + chin*0.13*exp(-cn*cn)*jawBand - vFace*0.025*lower);"
|
||||||
|
" vec2 dl = (p-le)/er; float wl = 1.0-smoothstep(0.0,1.0,length(dl));"
|
||||||
|
" p -= (p-le)*bigEye*0.33*wl*wl;"
|
||||||
|
" vec2 dr = (p-re)/er; float wr = 1.0-smoothstep(0.0,1.0,length(dr));"
|
||||||
|
" p -= (p-re)*bigEye*0.33*wr*wr; return p; }";
|
||||||
|
#pragma clang diagnostic push
|
||||||
|
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||||
|
_warp = [CIWarpKernel kernelWithString:source];
|
||||||
|
_ellipse = [CIColorKernel kernelWithString:@"kernel vec4 pgEllipse(vec2 c, vec2 r, float inner) { float v=1.0-smoothstep(inner,1.0,length((destCoord()-c)/max(r,vec2(0.001)))); return vec4(v,v,v,1.0); }"];
|
||||||
|
_subtract = [CIColorKernel kernelWithString:@"kernel vec4 pgSubtract(__sample a, __sample b) { float v=max(0.0,a.r-b.r); return vec4(v,v,v,1.0); }"];
|
||||||
|
_box = [CIColorKernel kernelWithString:@"kernel vec4 pgBox(vec4 b, float width, vec4 color) { vec2 p=destCoord(); float inside=step(b.x,p.x)*step(b.y,p.y)*step(p.x,b.z)*step(p.y,b.w); float edge=min(min(abs(p.x-b.x),abs(p.x-b.z)),min(abs(p.y-b.y),abs(p.y-b.w))); float a=inside*(1.0-smoothstep(width,width+1.0,edge)); return color*a; }"];
|
||||||
|
#pragma clang diagnostic pop
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<VNFaceObservation *> *)observationsInImage:(CIImage *)image settings:(PGBeautySettings)settings error:(NSError **)error {
|
||||||
|
CGRect extent = image.extent;
|
||||||
|
CGFloat longest = MAX(extent.size.width, extent.size.height);
|
||||||
|
CGFloat scale = MIN(1.0, (settings.fastMultiFace ? 720.0 : 1280.0) / longest);
|
||||||
|
CIImage *prepared = [image imageByApplyingTransform:CGAffineTransformMakeTranslation(-extent.origin.x, -extent.origin.y)];
|
||||||
|
prepared = [prepared imageByApplyingTransform:CGAffineTransformMakeScale(scale, scale)];
|
||||||
|
VNDetectFaceLandmarksRequest *request = [[VNDetectFaceLandmarksRequest alloc] init];
|
||||||
|
// An image request handler is intentionally local to one invocation. A shared
|
||||||
|
// sequence handler would make parallel / reverse render order observable.
|
||||||
|
VNImageRequestHandler *handler = [[VNImageRequestHandler alloc] initWithCIImage:prepared options:@{}];
|
||||||
|
if (![handler performRequests:@[request] error:error]) return nil;
|
||||||
|
NSArray<VNFaceObservation *> *faces = request.results ?: @[];
|
||||||
|
return [faces sortedArrayUsingComparator:^NSComparisonResult(VNFaceObservation *a, VNFaceObservation *b) {
|
||||||
|
CGRect x = a.boundingBox, y = b.boundingBox;
|
||||||
|
if (CGRectGetMidX(x) != CGRectGetMidX(y)) return CGRectGetMidX(x) < CGRectGetMidX(y) ? NSOrderedAscending : NSOrderedDescending;
|
||||||
|
if (CGRectGetMidY(x) != CGRectGetMidY(y)) return CGRectGetMidY(x) < CGRectGetMidY(y) ? NSOrderedAscending : NSOrderedDescending;
|
||||||
|
return x.size.width > y.size.width ? NSOrderedAscending : NSOrderedDescending;
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSDictionary *)recordForTime:(CMTime)time target:(NSUInteger)target nearest:(BOOL)nearest {
|
||||||
|
NSString *key = PGTimeKey(time);
|
||||||
|
if (!key || target >= 3) return nil;
|
||||||
|
[_lock lock];
|
||||||
|
NSDictionary *record = _targets[target][key];
|
||||||
|
if (nearest && [record[@"faces"] count] == 0) record = nil;
|
||||||
|
if (!record && nearest) {
|
||||||
|
// Pick a deterministic analyzed identity, retaining the last valid face
|
||||||
|
// across lost detections. Only an explicit reset/reselection reacquires
|
||||||
|
// a different identity; render ordering never updates this anchor.
|
||||||
|
double distance = DBL_MAX;
|
||||||
|
CMTime selectedTime = kCMTimeInvalid;
|
||||||
|
for (NSDictionary *candidate in _targets[target].allValues) {
|
||||||
|
if ([candidate[@"faces"] count] == 0) continue;
|
||||||
|
CMTime stored = CMTimeMakeWithEpoch([candidate[@"value"] longLongValue], [candidate[@"scale"] intValue], [candidate[@"epoch"] longLongValue]);
|
||||||
|
if (stored.epoch != time.epoch) continue;
|
||||||
|
double delta = fabs(CMTimeGetSeconds(CMTimeSubtract(stored, time)));
|
||||||
|
if (delta < distance || (delta == distance && CMTIME_IS_VALID(selectedTime) && CMTimeCompare(stored, selectedTime) < 0)) {
|
||||||
|
record = candidate; distance = delta; selectedTime = stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[_lock unlock];
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<NSDictionary *> *)geometryForObservations:(NSArray<VNFaceObservation *> *)observations
|
||||||
|
settings:(PGBeautySettings)settings previous:(NSDictionary *)previous {
|
||||||
|
BOOL selectOne = settings.singleFace || settings.trackingEnabled;
|
||||||
|
if (!selectOne) {
|
||||||
|
NSMutableArray *all = [NSMutableArray array];
|
||||||
|
for (VNFaceObservation *face in observations) if (PGValidRect(face.boundingBox) && all.count < 16) [all addObject:PGGeometry(face)];
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
CGPoint selection = CGPointMake(PGClamp(settings.selectionX, 0, 1), PGClamp(settings.selectionY, 0, 1));
|
||||||
|
NSArray *previousFaces = previous[@"faces"];
|
||||||
|
CGRect prior = previousFaces.count ? PGArrayRect(previousFaces.firstObject[@"box"]) : CGRectZero;
|
||||||
|
BOOL guard = settings.faceJumpGuardRange > 0 && PGValidRect(prior);
|
||||||
|
CGPoint reference = guard ? PGCenter(prior) : selection;
|
||||||
|
VNFaceObservation *best = nil;
|
||||||
|
double bestScore = DBL_MAX;
|
||||||
|
for (VNFaceObservation *face in observations) {
|
||||||
|
CGRect box = face.boundingBox;
|
||||||
|
if (!PGValidRect(box)) continue;
|
||||||
|
CGPoint center = PGCenter(box);
|
||||||
|
double dx = (center.x - reference.x), dy = (center.y - reference.y);
|
||||||
|
if (guard) {
|
||||||
|
double radius = MAX(prior.size.width, prior.size.height) * PGClamp(settings.faceJumpGuardRange, 0.1, 8);
|
||||||
|
double ratio = box.size.width / prior.size.width;
|
||||||
|
if (hypot(dx, dy) > radius || ratio < 0.4 || ratio > 2.5) continue;
|
||||||
|
}
|
||||||
|
double score = dx * dx + dy * dy;
|
||||||
|
if (!guard && CGRectContainsPoint(box, selection)) score -= 2;
|
||||||
|
if (score < bestScore) { best = face; bestScore = score; }
|
||||||
|
}
|
||||||
|
return best ? @[PGGeometry(best)] : @[];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)analyzeImage:(CIImage *)image settings:(PGBeautySettings)settings time:(CMTime)time error:(NSError **)error {
|
||||||
|
NSString *key = PGTimeKey(time);
|
||||||
|
if (!image || !key || CGRectIsInfinite(image.extent) || CGRectIsEmpty(image.extent)) {
|
||||||
|
if (error) *error = [NSError errorWithDomain:PGFaceErrorDomain code:1 userInfo:@{NSLocalizedDescriptionKey:@"Face analysis requires a finite image and valid frame time."}];
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
NSArray *observations = [self observationsInImage:image settings:settings error:error];
|
||||||
|
if (!observations) return NO;
|
||||||
|
NSUInteger target = MIN((NSUInteger)settings.targetIndex, 2);
|
||||||
|
NSDictionary *prior = [self recordForTime:time target:target nearest:YES];
|
||||||
|
NSArray *geometry = [self geometryForObservations:observations settings:settings previous:prior];
|
||||||
|
NSDictionary *record = @{@"value":@(time.value), @"scale":@(time.timescale), @"epoch":@(time.epoch), @"faces":geometry};
|
||||||
|
[_lock lock];
|
||||||
|
_targets[target][key] = record;
|
||||||
|
[_lock unlock];
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CIImage *)ellipseInRect:(CGRect)rect extent:(CGRect)extent inner:(CGFloat)inner {
|
||||||
|
return [_ellipse applyWithExtent:extent arguments:@[PGVector(PGCenter(rect)), [CIVector vectorWithX:rect.size.width * 0.5 Y:rect.size.height * 0.5], @(inner)]];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CIImage *)warpImage:(CIImage *)image geometry:(NSArray<NSDictionary *> *)geometry settings:(PGBeautySettings)s extent:(CGRect)extent {
|
||||||
|
if (!(s.faceThin || s.faceBigEye || s.faceShort || s.faceNarrow || s.faceChin || s.faceV || s.faceJaw || s.faceCheekbone || s.faceSmallHead)) return [image imageByCroppingToRect:extent];
|
||||||
|
CIImage *result = image;
|
||||||
|
for (NSDictionary *face in geometry) {
|
||||||
|
CGRect box = PGArrayRect(face[@"box"]), pixels = PGToPixels(box, extent);
|
||||||
|
CGRect le = PGRegionBounds(face, @"leftEye", PGInFace(box, 0.16, 0.53, 0.25, 0.15));
|
||||||
|
CGRect re = PGRegionBounds(face, @"rightEye", PGInFace(box, 0.59, 0.53, 0.25, 0.15));
|
||||||
|
CGPoint left = PGCenter(PGToPixels(le, extent)), right = PGCenter(PGToPixels(re, extent));
|
||||||
|
NSArray *arguments = @[PGVector(PGCenter(pixels)), [CIVector vectorWithX:pixels.size.width Y:pixels.size.height],
|
||||||
|
PGVector(left), PGVector(right), [CIVector vectorWithX:MAX(2, pixels.size.width * 0.24) Y:MAX(2, pixels.size.height * 0.17)],
|
||||||
|
@(PGClamp(s.faceThin, 0, 1)), @(PGClamp(s.faceBigEye, 0, 1)), @(PGClamp(s.faceShort, -1, 1)),
|
||||||
|
@(PGClamp(s.faceNarrow, -1, 1)), @(PGClamp(s.faceChin, -1, 1)), @(PGClamp(s.faceV, 0, 1)),
|
||||||
|
@(PGClamp(s.faceJaw, 0, 1)), @(PGClamp(s.faceCheekbone, 0, 1)), @(PGClamp(s.faceSmallHead, 0, 1)), @(PGClamp(s.faceSmallHeadRange, 0, 1))];
|
||||||
|
CIImage *input = [result imageByClampingToExtent];
|
||||||
|
result = [_warp applyWithExtent:extent roiCallback:^CGRect(int index, CGRect rect) { (void)index; (void)rect; return extent; } inputImage:input arguments:arguments];
|
||||||
|
if (!result) return nil;
|
||||||
|
}
|
||||||
|
return [result imageByCroppingToRect:extent];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (PGFaceFrame *)processImage:(CIImage *)image settings:(PGBeautySettings)settings time:(CMTime)time error:(NSError **)error {
|
||||||
|
CGRect extent = image.extent;
|
||||||
|
if (!image || CGRectIsInfinite(extent) || CGRectIsEmpty(extent) || !_ellipse || !_warp || !_box || !_subtract) {
|
||||||
|
if (error) *error = [NSError errorWithDomain:PGFaceErrorDomain code:2 userInfo:@{NSLocalizedDescriptionKey:@"Unable to initialize face processing for this image."}];
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
NSDictionary *record = settings.trackingEnabled ? [self recordForTime:time target:MIN((NSUInteger)settings.targetIndex, 2) nearest:NO] : nil;
|
||||||
|
NSArray<NSDictionary *> *geometry;
|
||||||
|
NSArray<VNFaceObservation *> *observations;
|
||||||
|
if (record) {
|
||||||
|
geometry = record[@"faces"];
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
observations = selected;
|
||||||
|
}
|
||||||
|
CIImage *skin = PGBlack(extent), *teeth = PGBlack(extent), *eyes = PGBlack(extent), *protection = PGBlack(extent), *overlay = PGTransparent(extent);
|
||||||
|
for (NSDictionary *face in geometry) {
|
||||||
|
CGRect box = PGArrayRect(face[@"box"]);
|
||||||
|
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]);
|
||||||
|
NSArray *names = @[@"leftEye", @"rightEye", @"leftBrow", @"rightBrow", @"lips", @"nose"];
|
||||||
|
NSArray<NSValue *> *fallbacks = @[[NSValue valueWithRect:PGInFace(box, 0.16, 0.53, 0.25, 0.15)],
|
||||||
|
[NSValue valueWithRect:PGInFace(box, 0.59, 0.53, 0.25, 0.15)],
|
||||||
|
[NSValue valueWithRect:PGInFace(box, 0.12, 0.72, 0.29, 0.08)],
|
||||||
|
[NSValue valueWithRect:PGInFace(box, 0.59, 0.72, 0.29, 0.08)],
|
||||||
|
[NSValue valueWithRect:PGInFace(box, 0.27, 0.16, 0.46, 0.18)],
|
||||||
|
[NSValue valueWithRect:PGInFace(box, 0.39, 0.33, 0.22, 0.16)]];
|
||||||
|
for (NSUInteger i = 0; i < names.count; i++) {
|
||||||
|
CGRect region = PGToPixels(PGRegionBounds(face, names[i], fallbacks[i].rectValue), extent);
|
||||||
|
CGRect expanded = CGRectInset(region, -region.size.width * 0.15, -region.size.height * 0.35);
|
||||||
|
CIImage *mask = [self ellipseInRect:expanded extent:extent inner:0.45];
|
||||||
|
protection = PGMaximum(protection, mask);
|
||||||
|
if (i < 2) eyes = PGMaximum(eyes, [self ellipseInRect:region extent:extent inner:0.45]);
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
if (settings.showFaceBoxes || settings.showTrackBox) {
|
||||||
|
CGRect pixels = PGToPixels(box, extent);
|
||||||
|
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]]];
|
||||||
|
overlay = [outline imageByCompositingOverImage:overlay];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (settings.featureProtection) skin = [_subtract applyWithExtent:extent arguments:@[skin, protection]];
|
||||||
|
PGFaceFrame *frame = [[PGFaceFrame alloc] init];
|
||||||
|
frame.faces = observations ?: @[];
|
||||||
|
frame.image = [self warpImage:image geometry:geometry settings:settings extent:extent];
|
||||||
|
frame.skinMask = [self warpImage:skin geometry:geometry settings:settings extent:extent];
|
||||||
|
frame.teethMask = [self warpImage:teeth geometry:geometry settings:settings extent:extent];
|
||||||
|
frame.eyeMask = [self warpImage:eyes geometry:geometry settings:settings extent:extent];
|
||||||
|
frame.overlayImage = [overlay imageByCroppingToRect:extent];
|
||||||
|
if (!frame.image || !frame.skinMask || !frame.teethMask || !frame.eyeMask) {
|
||||||
|
if (error) *error = [NSError errorWithDomain:PGFaceErrorDomain code:3 userInfo:@{NSLocalizedDescriptionKey:@"Unable to apply face geometry."}];
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)resetTracking {
|
||||||
|
[_lock lock];
|
||||||
|
for (NSMutableDictionary *target in _targets) [target removeAllObjects];
|
||||||
|
[_lock unlock];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSData *)trackingDataForTarget:(NSUInteger)target {
|
||||||
|
if (target >= 3) return nil;
|
||||||
|
[_lock lock];
|
||||||
|
NSDictionary *frames = [_targets[target] copy];
|
||||||
|
[_lock unlock];
|
||||||
|
if (!frames.count) return nil;
|
||||||
|
return [NSPropertyListSerialization dataWithPropertyList:@{@"version":@1, @"frames":frames} format:NSPropertyListBinaryFormat_v1_0 options:0 error:nil];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setTrackingData:(NSData *)data forTarget:(NSUInteger)target {
|
||||||
|
if (target >= 3) return;
|
||||||
|
NSMutableDictionary *frames = [NSMutableDictionary dictionary];
|
||||||
|
if (data.length && data.length <= 128 * 1024 * 1024) {
|
||||||
|
NSDictionary *archive = [NSPropertyListSerialization propertyListWithData:data options:NSPropertyListImmutable format:nil error:nil];
|
||||||
|
if ([archive isKindOfClass:NSDictionary.class] && [archive[@"version"] isEqual:@1] && [archive[@"frames"] isKindOfClass:NSDictionary.class] && [archive[@"frames"] count] <= 250000) {
|
||||||
|
for (id key in archive[@"frames"]) {
|
||||||
|
id raw = archive[@"frames"][key];
|
||||||
|
if (![key isKindOfClass:NSString.class] || ![raw isKindOfClass:NSDictionary.class]) continue;
|
||||||
|
NSNumber *value = raw[@"value"], *scale = raw[@"scale"], *epoch = raw[@"epoch"];
|
||||||
|
if (![value isKindOfClass:NSNumber.class] || ![scale isKindOfClass:NSNumber.class] || ![epoch isKindOfClass:NSNumber.class] || scale.intValue <= 0) continue;
|
||||||
|
CMTime time = CMTimeMakeWithEpoch(value.longLongValue, scale.intValue, epoch.longLongValue);
|
||||||
|
if (![key isEqualToString:PGTimeKey(time)] || ![raw[@"faces"] isKindOfClass:NSArray.class] || [raw[@"faces"] count] > 16) continue;
|
||||||
|
NSMutableArray *faces = [NSMutableArray array];
|
||||||
|
BOOL valid = YES;
|
||||||
|
for (id geometry in raw[@"faces"]) {
|
||||||
|
NSDictionary *face = PGSanitizedGeometry(geometry);
|
||||||
|
if (!face) { valid = NO; break; }
|
||||||
|
[faces addObject:face];
|
||||||
|
}
|
||||||
|
if (valid) frames[key] = @{@"value":value, @"scale":scale, @"epoch":epoch, @"faces":[faces copy]};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[_lock lock];
|
||||||
|
_targets[target] = frames;
|
||||||
|
[_lock unlock];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -46,7 +46,9 @@ static BOOL PGFail(NSError **error, NSString *message) {
|
|||||||
}
|
}
|
||||||
static CIImage *PGImageFromTile(FxImageTile *tile) {
|
static CIImage *PGImageFromTile(FxImageTile *tile) {
|
||||||
if (!tile.ioSurface) return nil;
|
if (!tile.ioSurface) return nil;
|
||||||
NSDictionary *options = tile.colorSpace ? @{kCIImageColorSpace:(__bridge id)tile.colorSpace} : @{};
|
// The host already supplies video-gamma RGB. Keep those numeric values in
|
||||||
|
// every stage; implicit Core Image linearization changes mask thresholds.
|
||||||
|
NSDictionary *options = @{kCIImageColorSpace:NSNull.null};
|
||||||
CIImage *image = [CIImage imageWithIOSurface:(__bridge IOSurfaceRef)tile.ioSurface options:options];
|
CIImage *image = [CIImage imageWithIOSurface:(__bridge IOSurfaceRef)tile.ioSurface options:options];
|
||||||
if (tile.imageOrigin == kFxImageOrigin_TOP_LEFT) {
|
if (tile.imageOrigin == kFxImageOrigin_TOP_LEFT) {
|
||||||
image = [image imageByApplyingTransform:CGAffineTransformMake(1,0,0,-1,0,image.extent.size.height)];
|
image = [image imageByApplyingTransform:CGAffineTransformMake(1,0,0,-1,0,image.extent.size.height)];
|
||||||
@@ -58,11 +60,13 @@ static CIImage *PGImageFromTile(FxImageTile *tile) {
|
|||||||
@interface beatygrilPlugIn ()
|
@interface beatygrilPlugIn ()
|
||||||
@property (strong) PGBeautyRenderer *beautyRenderer;
|
@property (strong) PGBeautyRenderer *beautyRenderer;
|
||||||
@property (strong) PGFaceProcessor *faceProcessor;
|
@property (strong) PGFaceProcessor *faceProcessor;
|
||||||
|
@property (strong) PGFaceProcessor *analysisFaceProcessor;
|
||||||
@property (strong) NSMutableDictionary<NSNumber *,CIContext *> *contexts;
|
@property (strong) NSMutableDictionary<NSNumber *,CIContext *> *contexts;
|
||||||
@property PGBeautySettings analysisSettings;
|
@property PGBeautySettings analysisSettings;
|
||||||
@property CMTime analysisTime;
|
@property CMTime analysisTime;
|
||||||
@property NSUInteger analyzedFrames;
|
@property NSUInteger analyzedFrames;
|
||||||
@property BOOL analyzing;
|
@property BOOL analyzing;
|
||||||
|
@property BOOL analysisForward;
|
||||||
@property (strong) NSArray<NSString *> *loadedTracks;
|
@property (strong) NSArray<NSString *> *loadedTracks;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@@ -71,6 +75,7 @@ static CIImage *PGImageFromTile(FxImageTile *tile) {
|
|||||||
if ((self=[super init])) {
|
if ((self=[super init])) {
|
||||||
_apiManager=manager;
|
_apiManager=manager;
|
||||||
_beautyRenderer=[PGBeautyRenderer new]; _faceProcessor=[PGFaceProcessor new];
|
_beautyRenderer=[PGBeautyRenderer new]; _faceProcessor=[PGFaceProcessor new];
|
||||||
|
_analysisFaceProcessor=[PGFaceProcessor new];
|
||||||
_contexts=[NSMutableDictionary new]; _analysisSettings=PGBeautyDefaults();
|
_contexts=[NSMutableDictionary new]; _analysisSettings=PGBeautyDefaults();
|
||||||
_analysisTime=kCMTimeZero;
|
_analysisTime=kCMTimeZero;
|
||||||
}
|
}
|
||||||
@@ -178,7 +183,6 @@ static CIImage *PGImageFromTile(FxImageTile *tile) {
|
|||||||
if(!state) return PGFail(error,@"缺少渲染状态输出位置。");
|
if(!state) return PGFail(error,@"缺少渲染状态输出位置。");
|
||||||
if(![_apiManager apiForProtocol:@protocol(FxParameterRetrievalAPI_v6)]) return PGFail(error,@"无法读取宿主参数。");
|
if(![_apiManager apiForProtocol:@protocol(FxParameterRetrievalAPI_v6)]) return PGFail(error,@"无法读取宿主参数。");
|
||||||
PGBeautySettings s=[self settingsAtTime:time];
|
PGBeautySettings s=[self settingsAtTime:time];
|
||||||
@synchronized(self) {_analysisTime=time;}
|
|
||||||
*state=[NSPropertyListSerialization dataWithPropertyList:@{@"settings":[NSData dataWithBytes:&s length:sizeof(s)],@"tracks":[self trackStrings]} format:NSPropertyListBinaryFormat_v1_0 options:0 error:error];
|
*state=[NSPropertyListSerialization dataWithPropertyList:@{@"settings":[NSData dataWithBytes:&s length:sizeof(s)],@"tracks":[self trackStrings]} format:NSPropertyListBinaryFormat_v1_0 options:0 error:error];
|
||||||
return *state!=nil;
|
return *state!=nil;
|
||||||
}
|
}
|
||||||
@@ -198,21 +202,21 @@ static CIImage *PGImageFromTile(FxImageTile *tile) {
|
|||||||
for(id<MTLDevice> candidate in MTLCopyAllDevices()) if(candidate.registryID==tile.deviceRegistryID) {device=candidate;break;}
|
for(id<MTLDevice> candidate in MTLCopyAllDevices()) if(candidate.registryID==tile.deviceRegistryID) {device=candidate;break;}
|
||||||
if(!device) device=MTLCreateSystemDefaultDevice();
|
if(!device) device=MTLCreateSystemDefaultDevice();
|
||||||
if(!device) return nil;
|
if(!device) return nil;
|
||||||
_contexts[key]=[CIContext contextWithMTLDevice:device options:@{kCIContextCacheIntermediates:@NO}];
|
_contexts[key]=[CIContext contextWithMTLDevice:device options:@{kCIContextCacheIntermediates:@NO,kCIContextWorkingColorSpace:NSNull.null,kCIContextOutputColorSpace:NSNull.null}];
|
||||||
}
|
}
|
||||||
return _contexts[key];
|
return _contexts[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
- (BOOL)renderDestinationImage:(FxImageTile *)destination sourceImages:(NSArray<FxImageTile *> *)sources pluginState:(NSData *)state atTime:(CMTime)time error:(NSError **)error {
|
- (BOOL)renderDestinationImage:(FxImageTile *)destination sourceImages:(NSArray<FxImageTile *> *)sources pluginState:(NSData *)state atTime:(CMTime)time error:(NSError **)error {
|
||||||
@autoreleasepool {
|
@autoreleasepool {
|
||||||
if(sources.count==0||!sources.firstObject.ioSurface||!destination.ioSurface||state.length>16*1024*1024) return PGFail(error,@"宿主传入的画面或状态无效。");
|
if(sources.count==0||!sources.firstObject.ioSurface||!destination.ioSurface||state.length>320*1024*1024) return PGFail(error,@"宿主传入的画面或状态无效。");
|
||||||
NSDictionary *plist=[NSPropertyListSerialization propertyListWithData:state options:NSPropertyListImmutable format:nil error:error];
|
NSDictionary *plist=[NSPropertyListSerialization propertyListWithData:state options:NSPropertyListImmutable format:nil error:error];
|
||||||
if(![plist isKindOfClass:NSDictionary.class]||![plist[@"settings"] isKindOfClass:NSData.class]||[plist[@"settings"] length]!=sizeof(PGBeautySettings)) return PGFail(error,@"渲染状态版本无效。");
|
if(![plist isKindOfClass:NSDictionary.class]||![plist[@"settings"] isKindOfClass:NSData.class]||[plist[@"settings"] length]!=sizeof(PGBeautySettings)) return PGFail(error,@"渲染状态版本无效。");
|
||||||
PGBeautySettings s;[plist[@"settings"] getBytes:&s length:sizeof(s)];
|
PGBeautySettings s;[plist[@"settings"] getBytes:&s length:sizeof(s)];
|
||||||
if(s.version!=1) return PGFail(error,@"此版本不支持保存的渲染状态。");
|
if(s.version!=1) return PGFail(error,@"此版本不支持保存的渲染状态。");
|
||||||
CIImage *input=PGImageFromTile(sources.firstObject); if(!input) return PGFail(error,@"无法读取 IOSurface。");
|
CIImage *input=PGImageFromTile(sources.firstObject); if(!input) return PGFail(error,@"无法读取 IOSurface。");
|
||||||
CIContext *context=[self contextForTile:destination]; if(!context) return PGFail(error,@"没有可用的 Metal 图形设备。");
|
CIContext *context=[self contextForTile:destination]; if(!context) return PGFail(error,@"没有可用的 Metal 图形设备。");
|
||||||
CIImage *output=nil;
|
CIImage *output=nil; CIImage *overlay=nil;
|
||||||
@synchronized(_faceProcessor) {
|
@synchronized(_faceProcessor) {
|
||||||
NSArray *tracks=plist[@"tracks"];
|
NSArray *tracks=plist[@"tracks"];
|
||||||
if(![tracks isKindOfClass:NSArray.class]||tracks.count!=3) return PGFail(error,@"跟踪状态无效。");
|
if(![tracks isKindOfClass:NSArray.class]||tracks.count!=3) return PGFail(error,@"跟踪状态无效。");
|
||||||
@@ -225,18 +229,21 @@ static CIImage *PGImageFromTile(FxImageTile *tile) {
|
|||||||
}
|
}
|
||||||
PGFaceFrame *frame=[_faceProcessor processImage:input settings:s time:time error:error];
|
PGFaceFrame *frame=[_faceProcessor processImage:input settings:s time:time error:error];
|
||||||
if(!frame) return NO;
|
if(!frame) return NO;
|
||||||
output=[_beautyRenderer processImage:frame.image settings:s skinMask:frame.skinMask teethMask:frame.teethMask eyeMask:frame.eyeMask error:error];
|
PGBeautySettings beauty=s; beauty.mix=1;
|
||||||
|
output=[_beautyRenderer processImage:frame.image settings:beauty skinMask:frame.skinMask teethMask:frame.teethMask eyeMask:frame.eyeMask error:error];
|
||||||
|
overlay=frame.overlayImage;
|
||||||
}
|
}
|
||||||
if(!output) return NO;
|
if(!output) return NO;
|
||||||
// Blend from the original frame so zero mix also disables geometry.
|
// Blend from the original frame so zero mix also disables geometry.
|
||||||
if(s.mix<1 && !s.showMask) {
|
if(s.mix<1 && !s.showMask) {
|
||||||
output=[output imageByApplyingFilter:@"CIDissolveTransition" withInputParameters:@{kCIInputTargetImageKey:input,kCIInputTimeKey:@(1-s.mix)}];
|
output=[output imageByApplyingFilter:@"CIDissolveTransition" withInputParameters:@{kCIInputTargetImageKey:input,kCIInputTimeKey:@(1-s.mix)}];
|
||||||
}
|
}
|
||||||
|
if(overlay && !s.showMask) output=[overlay imageByCompositingOverImage:output];
|
||||||
FxRect b=destination.tilePixelBounds;
|
FxRect b=destination.tilePixelBounds;
|
||||||
output=[output imageByApplyingTransform:CGAffineTransformMakeTranslation(-b.left,-b.bottom)];
|
output=[output imageByApplyingTransform:CGAffineTransformMakeTranslation(-b.left,-b.bottom)];
|
||||||
size_t width=IOSurfaceGetWidth((__bridge IOSurfaceRef)destination.ioSurface),height=IOSurfaceGetHeight((__bridge IOSurfaceRef)destination.ioSurface);
|
size_t width=IOSurfaceGetWidth((__bridge IOSurfaceRef)destination.ioSurface),height=IOSurfaceGetHeight((__bridge IOSurfaceRef)destination.ioSurface);
|
||||||
if(destination.imageOrigin==kFxImageOrigin_TOP_LEFT) output=[output imageByApplyingTransform:CGAffineTransformMake(1,0,0,-1,0,height)];
|
if(destination.imageOrigin==kFxImageOrigin_TOP_LEFT) output=[output imageByApplyingTransform:CGAffineTransformMake(1,0,0,-1,0,height)];
|
||||||
@try {[context render:output toIOSurface:(__bridge IOSurfaceRef)destination.ioSurface bounds:CGRectMake(0,0,width,height) colorSpace:destination.colorSpace];}
|
@try {[context render:output toIOSurface:(__bridge IOSurfaceRef)destination.ioSurface bounds:CGRectMake(0,0,width,height) colorSpace:nil];}
|
||||||
@catch(NSException *exception) {return PGFail(error,exception.reason?:@"图像处理失败。");}
|
@catch(NSException *exception) {return PGFail(error,exception.reason?:@"图像处理失败。");}
|
||||||
return YES;
|
return YES;
|
||||||
}
|
}
|
||||||
@@ -245,26 +252,49 @@ static CIImage *PGImageFromTile(FxImageTile *tile) {
|
|||||||
id<FxParameterSettingAPI_v5> api=[_apiManager apiForProtocol:@protocol(FxParameterSettingAPI_v5)];
|
id<FxParameterSettingAPI_v5> api=[_apiManager apiForProtocol:@protocol(FxParameterSettingAPI_v5)];
|
||||||
[api setStringParameterValue:status toParameter:PGStatus];
|
[api setStringParameterValue:status toParameter:PGStatus];
|
||||||
}
|
}
|
||||||
|
- (CMTime)currentHostTime {
|
||||||
|
id<FxCustomParameterActionAPI_v4> api=[_apiManager apiForProtocol:@protocol(FxCustomParameterActionAPI_v4)];
|
||||||
|
return api ? [api currentTime] : kCMTimeZero;
|
||||||
|
}
|
||||||
- (void)startAnalysisForward:(BOOL)forward {
|
- (void)startAnalysisForward:(BOOL)forward {
|
||||||
|
if(_analyzing) {[self setStatus:@"请先等待当前分析结束或取消分析。"];return;}
|
||||||
NSError *error=nil;
|
NSError *error=nil;
|
||||||
PGBeautySettings s=[self settingsAtTime:_analysisTime];
|
|
||||||
@synchronized(_faceProcessor) {[_faceProcessor setTrackingData:nil forTarget:s.targetIndex];}
|
|
||||||
_analysisSettings=s;_analysisSettings.trackingEnabled=1;_analysisSettings.singleFace=1;
|
|
||||||
id<FxAnalysisAPI> api=[_apiManager apiForProtocol:@protocol(FxAnalysisAPI)];
|
id<FxAnalysisAPI> api=[_apiManager apiForProtocol:@protocol(FxAnalysisAPI)];
|
||||||
|
if(!api) {[self setStatus:@"当前宿主不提供帧分析接口。"];return;}
|
||||||
|
_analysisTime=[self currentHostTime];
|
||||||
|
PGBeautySettings s=[self settingsAtTime:_analysisTime];
|
||||||
|
NSArray<NSString *> *tracks=[self trackStrings];
|
||||||
|
// A separate processor owns the mutable analysis session. Render snapshots
|
||||||
|
// cannot overwrite records being accumulated by the analyzer.
|
||||||
|
_analysisFaceProcessor=[PGFaceProcessor new];
|
||||||
|
[_analysisFaceProcessor setTrackingData:[[NSData alloc] initWithBase64EncodedString:tracks[s.targetIndex] options:0] forTarget:s.targetIndex];
|
||||||
|
_analysisSettings=s;_analysisSettings.trackingEnabled=1;_analysisSettings.singleFace=1;
|
||||||
|
_analysisForward=forward;_analyzing=YES;
|
||||||
BOOL ok=forward?[api startForwardAnalysis:kFxAnalysisLocation_CPU error:&error]:[api startBackwardAnalysis:kFxAnalysisLocation_CPU error:&error];
|
BOOL ok=forward?[api startForwardAnalysis:kFxAnalysisLocation_CPU error:&error]:[api startBackwardAnalysis:kFxAnalysisLocation_CPU error:&error];
|
||||||
[self setStatus:ok?@"正在分析所选人脸…":(error.localizedDescription?:@"当前宿主不提供帧分析接口。")];
|
if(!ok) _analyzing=NO;
|
||||||
|
[self setStatus:ok?@"正在分析所选人脸…":(error.localizedDescription?:@"宿主未能开始帧分析。")];
|
||||||
}
|
}
|
||||||
- (void)analyzeForward {[self startAnalysisForward:YES];}
|
- (void)analyzeForward {[self startAnalysisForward:YES];}
|
||||||
- (void)analyzeBackward {[self startAnalysisForward:NO];}
|
- (void)analyzeBackward {[self startAnalysisForward:NO];}
|
||||||
- (void)clearTracking {
|
- (void)clearTracking {
|
||||||
PGBeautySettings s=[self settingsAtTime:_analysisTime];
|
PGBeautySettings s=[self settingsAtTime:[self currentHostTime]];
|
||||||
@synchronized(_faceProcessor) {[_faceProcessor setTrackingData:nil forTarget:s.targetIndex];_loadedTracks=nil;}
|
@synchronized(_faceProcessor) {[_faceProcessor setTrackingData:nil forTarget:s.targetIndex];_loadedTracks=nil;}
|
||||||
id<FxParameterSettingAPI_v5> api=[_apiManager apiForProtocol:@protocol(FxParameterSettingAPI_v5)];
|
id<FxParameterSettingAPI_v5> api=[_apiManager apiForProtocol:@protocol(FxParameterSettingAPI_v5)];
|
||||||
[api setStringParameterValue:@"" toParameter:PGTrackData1+s.targetIndex];
|
[api setStringParameterValue:@"" toParameter:PGTrackData1+s.targetIndex];
|
||||||
[self setStatus:@"已清除当前目标的分析数据"];
|
[self setStatus:@"已清除当前目标的分析数据"];
|
||||||
}
|
}
|
||||||
- (BOOL)desiredAnalysisTimeRange:(CMTimeRange *)desired forInputWithTimeRange:(CMTimeRange)range error:(NSError **)error {
|
- (BOOL)desiredAnalysisTimeRange:(CMTimeRange *)desired forInputWithTimeRange:(CMTimeRange)range error:(NSError **)error {
|
||||||
if(!desired) return PGFail(error,@"缺少分析时间范围。");*desired=range;return YES;
|
if(!desired) return PGFail(error,@"缺少分析时间范围。");
|
||||||
|
*desired=range;
|
||||||
|
if(CMTIMERANGE_IS_VALID(range) && CMTIME_IS_NUMERIC(_analysisTime) && CMTimeRangeContainsTime(range,_analysisTime)) {
|
||||||
|
if(_analysisForward) *desired=CMTimeRangeFromTimeToTime(_analysisTime,CMTimeRangeGetEnd(range));
|
||||||
|
else {
|
||||||
|
CMTime last=CMTimeAdd(_analysisTime,CMTimeMake(1,MAX(1,_analysisTime.timescale)));
|
||||||
|
if(CMTimeCompare(last,CMTimeRangeGetEnd(range))>0) last=CMTimeRangeGetEnd(range);
|
||||||
|
*desired=CMTimeRangeFromTimeToTime(range.start,last);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return YES;
|
||||||
}
|
}
|
||||||
- (BOOL)setupAnalysisForTimeRange:(CMTimeRange)range frameDuration:(CMTime)duration error:(NSError **)error {
|
- (BOOL)setupAnalysisForTimeRange:(CMTimeRange)range frameDuration:(CMTime)duration error:(NSError **)error {
|
||||||
_analyzedFrames=0;_analyzing=YES;return YES;
|
_analyzedFrames=0;_analyzing=YES;return YES;
|
||||||
@@ -272,14 +302,19 @@ static CIImage *PGImageFromTile(FxImageTile *tile) {
|
|||||||
- (BOOL)analyzeFrame:(FxImageTile *)frame atTime:(CMTime)time error:(NSError **)error {
|
- (BOOL)analyzeFrame:(FxImageTile *)frame atTime:(CMTime)time error:(NSError **)error {
|
||||||
CIImage *image=PGImageFromTile(frame);if(!image) return PGFail(error,@"无法读取分析帧。");
|
CIImage *image=PGImageFromTile(frame);if(!image) return PGFail(error,@"无法读取分析帧。");
|
||||||
BOOL ok=NO;
|
BOOL ok=NO;
|
||||||
@synchronized(_faceProcessor) {ok=[_faceProcessor analyzeImage:image settings:_analysisSettings time:time error:error];}
|
@synchronized(_analysisFaceProcessor) {ok=[_analysisFaceProcessor analyzeImage:image settings:_analysisSettings time:time error:error];}
|
||||||
if(ok) _analyzedFrames++;
|
if(ok) _analyzedFrames++;
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
- (BOOL)cleanupAnalysis:(NSError **)error {
|
- (BOOL)cleanupAnalysis:(NSError **)error {
|
||||||
id<FxParameterSettingAPI_v5> api=[_apiManager apiForProtocol:@protocol(FxParameterSettingAPI_v5)];
|
id<FxParameterSettingAPI_v5> api=[_apiManager apiForProtocol:@protocol(FxParameterSettingAPI_v5)];
|
||||||
NSData *data=nil;
|
NSData *data=nil;
|
||||||
@synchronized(_faceProcessor) {data=[_faceProcessor trackingDataForTarget:_analysisSettings.targetIndex];_loadedTracks=nil;}
|
@synchronized(_analysisFaceProcessor) {data=[_analysisFaceProcessor trackingDataForTarget:_analysisSettings.targetIndex];}
|
||||||
|
@synchronized(_faceProcessor) {_loadedTracks=nil;}
|
||||||
|
if(data.length>64*1024*1024) {
|
||||||
|
_analyzing=NO;
|
||||||
|
return PGFail(error,@"当前目标的跟踪数据超过 64 MB。请将长素材分段分析;原先保存的数据仍保留。");
|
||||||
|
}
|
||||||
if(data) {
|
if(data) {
|
||||||
if(![api setStringParameterValue:[data base64EncodedStringWithOptions:0] toParameter:PGTrackData1+_analysisSettings.targetIndex]) return PGFail(error,@"无法将分析数据保存到工程。");
|
if(![api setStringParameterValue:[data base64EncodedStringWithOptions:0] toParameter:PGTrackData1+_analysisSettings.targetIndex]) return PGFail(error,@"无法将分析数据保存到工程。");
|
||||||
[api setBoolValue:YES toParameter:PGTrackEnabled atTime:_analysisTime];
|
[api setBoolValue:YES toParameter:PGTrackEnabled atTime:_analysisTime];
|
||||||
|
|||||||
@@ -1,27 +1,34 @@
|
|||||||
//
|
|
||||||
// AppDelegate.m
|
|
||||||
// beatygril
|
|
||||||
//
|
|
||||||
// Created by dayu on 2026/10/21.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "AppDelegate.h"
|
#import "AppDelegate.h"
|
||||||
|
|
||||||
@interface AppDelegate ()
|
@interface AppDelegate ()
|
||||||
|
|
||||||
@property (weak) IBOutlet NSWindow *window;
|
@property (weak) IBOutlet NSWindow *window;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation AppDelegate
|
@implementation AppDelegate
|
||||||
|
- (void)applicationDidFinishLaunching:(NSNotification *)notification {
|
||||||
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
|
self.window.title=@"大鱼人像美化";
|
||||||
// Insert code here to initialize your application
|
[self.window setContentSize:NSMakeSize(600,360)];
|
||||||
|
NSView *content=[NSView new];self.window.contentView=content;
|
||||||
|
NSStackView *stack=[NSStackView new];stack.orientation=NSUserInterfaceLayoutOrientationVertical;
|
||||||
|
stack.alignment=NSLayoutAttributeLeading;stack.spacing=16;stack.translatesAutoresizingMaskIntoConstraints=NO;
|
||||||
|
[content addSubview:stack];
|
||||||
|
[NSLayoutConstraint activateConstraints:@[[stack.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:32],[stack.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-32],[stack.topAnchor constraintEqualToAnchor:content.topAnchor constant:30]]];
|
||||||
|
NSTextField *title=[NSTextField labelWithString:@"大鱼人像美化"];
|
||||||
|
title.font=[NSFont systemFontOfSize:28 weight:NSFontWeightSemibold];[stack addArrangedSubview:title];
|
||||||
|
NSTextField *subtitle=[NSTextField labelWithString:@"beatygril · FxPlug 4"];
|
||||||
|
subtitle.textColor=NSColor.secondaryLabelColor;[stack addArrangedSubview:subtitle];
|
||||||
|
NSTextField *description=[NSTextField wrappingLabelWithString:@"在 Motion 的滤镜资源库中选择「大鱼 → 大鱼人像美化」。Final Cut Pro 的效果浏览器中也可使用配套效果。\n\n磨皮、肤色、细节和脸型可分别开关。启用「只处理选中人脸」后,在检视器调整选脸位置,或直接点击画面选脸。选定目标后可向前、向后分析并将跟踪数据保存在工程中。"];
|
||||||
|
description.font=[NSFont systemFontOfSize:13];[stack addArrangedSubview:description];
|
||||||
|
NSStackView *buttons=[NSStackView new];buttons.orientation=NSUserInterfaceLayoutOrientationHorizontal;buttons.spacing=12;
|
||||||
|
[buttons addArrangedSubview:[NSButton buttonWithTitle:@"打开 Motion" target:self action:@selector(openMotion:)]];
|
||||||
|
[buttons addArrangedSubview:[NSButton buttonWithTitle:@"打开 Final Cut Pro" target:self action:@selector(openFinalCut:)]];
|
||||||
|
[stack addArrangedSubview:buttons];
|
||||||
|
NSTextField *note=[NSTextField wrappingLabelWithString:@"建议先调高中频磨皮,再按需调节肤色与脸型。肤色风格为本插件独立调色预设。"];
|
||||||
|
note.textColor=NSColor.secondaryLabelColor;note.font=[NSFont systemFontOfSize:11];[stack addArrangedSubview:note];
|
||||||
|
[self.window center];
|
||||||
}
|
}
|
||||||
|
- (void)openMotion:(id)sender {[self openApplication:@"com.apple.motionapp"];}
|
||||||
|
- (void)openFinalCut:(id)sender {[self openApplication:@"com.apple.FinalCut"];}
|
||||||
- (void)applicationWillTerminate:(NSNotification *)aNotification {
|
- (void)openApplication:(NSString *)identifier {
|
||||||
// Insert code here to tear down your application
|
NSURL *url=[NSWorkspace.sharedWorkspace URLForApplicationWithBundleIdentifier:identifier];
|
||||||
|
if(url) [NSWorkspace.sharedWorkspace openApplicationAtURL:url configuration:NSWorkspaceOpenConfiguration.configuration completionHandler:nil];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@end
|
@end
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"artifacts":[{"architecture":"arm64","digest":{"sha256":"958d2ae106a023febe244397402cdc3f1977cbc529811335fca15b93ed569a10"},"format":"mach-o"}],"captures":[],"environments":[],"providers":[{"id":"ghidra","name":"Ghidra","version":"12.1.4"},{"id":"rea-workflow","name":"REA composed investigation workflow","version":"1"}],"records":[{"analysis_profile":{"digest":"20565088524a2d0359b9b96f46ac48e679d774c7b0092c56abc06de8fdbec11c","parameters":{"analyzer_preset":"ghidra-default","annotation_policy":"atomic-function-entry-metadata-v1","architecture":"arm64","available_architectures":["arm64"],"compiler_spec_id":"auto-default","decompiler_jump_loads":true,"function_body_evidence":"complete-inclusive-ranges-v1","function_boundary_observations":"ghidra-terminal-call-limitations-v1","function_references":"complete-body-and-entry-reference-manager-v2","import_mode":"ephemeral-source-immutable","instruction_flow_evidence":"decoded-return-pcode-v1","jump_table_evidence":"typed-case-default-blocks-v1","language_id":"auto-from-header","load_image_observations":"source-mappings-entry-context-v2","loader":"auto-from-header","location_resolution":"explicit-address-exact-entry-symbol-first-v3","no_return_repair":"returning-imports-decoded-return-v2","process_launch":"inspected-jvm-launch-support-v1","string_inventory_evidence":"defined-data-coverage-v1","target_format":"mach-o","target_kind":"executable"},"provider":{"id":"ghidra","name":"Ghidra","version":"12.1.4"}},"authority":"shipped-artifact","confidence":"observed","environment":null,"evidence_id":"ev_bf25a1cd9c181e8f034fdee81259118798ae67e640ade71238f4afb707eab142","evidence_links":[],"limitations":["The session serves operations only after Ghidra reports default auto-analysis complete; incomplete analysis does not expose partial results.","The imported Program and temporary project are ephemeral and deleted on close. Annotation operations edit session database metadata; original executable bytes are never written.","Cancelling an active Ghidra request terminates its ephemeral database and discards session annotations. The selected target remains open; the next provider query imports the original artifact again.","Default-space addresses use lowercase 0x-prefixed hexadecimal; other address spaces use <percent-encoded-space>:0x<hex>.","Regex mode uses Java Pattern semantics. Matcher or compiler stack exhaustion returns a resource constraint without closing the session; literal mode avoids regex recursion and returns complete matching values.","macOS sessions require a matching executable Ghidra native decompiler; REA checks for it but does not build native components or change Gatekeeper quarantine state."],"locations":[],"normalized_result":[{"address":"0x100000c24","value":"AppDelegate::applicationDidFinishLaunching:"},{"address":"0x100000c28","value":"AppDelegate::mss_configureLaunchArtworkWindow"},{"address":"0x100000eb4","value":"AppDelegate::mss_pixelSizeForImage:"},{"address":"0x10000100c","value":"AppDelegate::mss_backingScaleFactorForScreen:"},{"address":"0x10000104c","value":"AppDelegate::mss_fittedSizeForSize:onScreen:"},{"address":"0x1000010e4","value":"AppDelegate::window"},{"address":"0x1000010ec","value":"AppDelegate::setWindow:"}],"operation":"search_procedures","parameters":{"case_sensitive":false,"mode":"regex","pattern":"(AppDelegate|ViewController|WindowController)"},"predicate_type":"rea.analysis","provider":{"id":"ghidra","name":"Ghidra","version":"12.1.4"},"raw_result":[{"address":"0x100000c24","value":"AppDelegate::applicationDidFinishLaunching:"},{"address":"0x100000c28","value":"AppDelegate::mss_configureLaunchArtworkWindow"},{"address":"0x100000eb4","value":"AppDelegate::mss_pixelSizeForImage:"},{"address":"0x10000100c","value":"AppDelegate::mss_backingScaleFactorForScreen:"},{"address":"0x10000104c","value":"AppDelegate::mss_fittedSizeForSize:onScreen:"},{"address":"0x1000010e4","value":"AppDelegate::window"},{"address":"0x1000010ec","value":"AppDelegate::setWindow:"}],"subject":{"architecture":"arm64","digest":{"sha256":"958d2ae106a023febe244397402cdc3f1977cbc529811335fca15b93ed569a10"},"format":"mach-o","local_path":"/private/tmp/paintfcp-rea.b3j9Te/MSE18V2-host-arm64","name":"MSE18V2-host-arm64"}},{"analysis_profile":{"digest":"575a577ce88c350b8ef1d4768986d124a16377363cbf22c68a4f73cc917881d4","parameters":{"upstream_analysis_profile":{"digest":"20565088524a2d0359b9b96f46ac48e679d774c7b0092c56abc06de8fdbec11c","parameters":{"analyzer_preset":"ghidra-default","annotation_policy":"atomic-function-entry-metadata-v1","architecture":"arm64","available_architectures":["arm64"],"compiler_spec_id":"auto-default","decompiler_jump_loads":true,"function_body_evidence":"complete-inclusive-ranges-v1","function_boundary_observations":"ghidra-terminal-call-limitations-v1","function_references":"complete-body-and-entry-reference-manager-v2","import_mode":"ephemeral-source-immutable","instruction_flow_evidence":"decoded-return-pcode-v1","jump_table_evidenLine truncated
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"artifacts":[{"architecture":"arm64","digest":{"sha256":"6c5d74bc844a5c5f745718b48ea10ec6635d08aa08a5b6dc6e62653980849f8a"},"format":"mach-o"},{"architecture":"arm64","digest":{"sha256":"f01e8c0b7e2676b2961950b0e48efdd2589af218e4b36de7d39f5d1c76852bfb"},"format":"mach-o"}],"captures":[],"environments":[],"providers":[{"id":"ghidra","name":"Ghidra","version":"12.1.4"},{"id":"native-macos","name":"macOS native inspection utilities","version":null},{"id":"rea-workflow","name":"REA composed investigation workflow","version":"1"}],"records":[{"analysis_profile":{"digest":"575a577ce88c350b8ef1d4768986d124a16377363cbf22c68a4f73cc917881d4","parameters":{"upstream_analysis_profile":{"digest":"20565088524a2d0359b9b96f46ac48e679d774c7b0092c56abc06de8fdbec11c","parameters":{"analyzer_preset":"ghidra-default","annotation_policy":"atomic-function-entry-metadata-v1","architecture":"arm64","available_architectures":["arm64"],"compiler_spec_id":"auto-default","decompiler_jump_loads":true,"function_body_evidence":"complete-inclusive-ranges-v1","function_boundary_observations":"ghidra-terminal-call-limitations-v1","function_references":"complete-body-and-entry-reference-manager-v2","import_mode":"ephemeral-source-immutable","instruction_flow_evidence":"decoded-return-pcode-v1","jump_table_evidence":"typed-case-default-blocks-v1","language_id":"auto-from-header","load_image_observations":"source-mappings-entry-context-v2","loader":"auto-from-header","location_resolution":"explicit-address-exact-entry-symbol-first-v3","no_return_repair":"returning-imports-decoded-return-v2","process_launch":"inspected-jvm-launch-support-v1","string_inventory_evidence":"defined-data-coverage-v1","target_format":"mach-o","target_kind":"executable"},"provider":{"id":"ghidra","name":"Ghidra","version":"12.1.4"}},"workflow_revision":2},"provider":{"id":"rea-workflow","name":"REA composed investigation workflow","version":"1"}},"authority":"shipped-artifact","confidence":"derived","environment":null,"evidence_id":"ev_0ea0cc3a7596fe3d6f4023cc4ab539bb81f65c6fdb3f28074712847e9dd048b8","evidence_links":[],"limitations":["Derived by an REA composed workflow."],"locations":[{"address":"0x1000353b0","kind":"address"},{"address":"0x1000370a0","kind":"address"},{"address":"0x1000373ec","kind":"address"},{"address":"0x100035c2c","kind":"address"}],"normalized_result":{"failed":0,"items":[{"address":"0x1000353b0","procedure":{"address":"0x1000353b0","name":"YUCIHighPassSkinSmoothingMaskGenerator::outputImage","status":"resolved"},"pseudocode":"\n/* WARNING: Function: _objc_autorelease replaced with injection: _objc_retain_fixup */\n/* Function Stack Size: 0x10 bytes */\n\nID YUCIHighPassSkinSmoothingMaskGenerator::outputImage(ID param_1,SEL param_2)\n\n{\n ID IVar1;\n ID IVar2;\n class_t *pcVar3;\n class_t *self;\n \n IVar1 = CIFilter::filterWithName_((ID)&_OBJC_CLASS___CIFilter,&cf_CIExposureAdjust);\n IVar2 = inputImage(param_1);\n CIFilter::setValue_forKey_(IVar1,IVar2,*(undefined8 *)PTR__kCIInputImageKey_1000781a8);\n CIFilter::setValue_forKey_\n (IVar1,&PTR__OBJC_CLASS___NSConstantDoubleNumber_10007d848,\n *(undefined8 *)PTR__kCIInputEVKey_1000781a0);\n pcVar3 = &objc::class_t::YUCIGreenBlueChannelOverlayBlend;\n _objc_alloc_init();\n IVar1 = CIFilter::outputImage(IVar1);\n objc_stub::setInputImage_((ID)pcVar3,IVar1);\n self = &objc::class_t::YUCIHighPass;\n _objc_alloc_init();\n IVar1 = objc_stub::outputImage((ID)pcVar3);\n objc_stub::setInputImage_((ID)self,IVar1);\n IVar1 = inputRadius(param_1);\n objc_stub::setInputRadius_((ID)self,IVar1);\n pcVar3 = &objc::class_t::YUCIHighPassSkinSmoothingMaskBoost;\n _objc_alloc_init();\n IVar1 = objc_stub::outputImage((ID)self);\n objc_stub::setInputImage_((ID)pcVar3,IVar1);\n IVar1 = objc_stub::outputImage((ID)pcVar3);\n return IVar1;\n}\n\n","status":"ok"},{"address":"0x1000370a0","procedure":{"address":"0x1000370a0","name":"FUN_1000370a0","status":"resolved"},"pseudocode":"\nvoid FUN_1000370a0(double param_1,undefined8 param_2,undefined8 param_3,undefined8 param_4,\n ID param_5,undefined8 param_6)\n\n{\n ID IVar1;\n ID IVar2;\n ID IVar3;\n undefined8 uVar4;\n undefined8 uVar5;\n ID IVar6;\n double dVar7;\n double dVar8;\n double dVar9;\n double dVar10;\n undefined8 extraout_d0;\n double dVar11;\n double dVar12;\n ID local_b8;\n ID IStack_b0;\n ID local_a8;\n ID local_a0;\n long local_98;\n \n local_98 = *(long *)PTR____stack_chk_guard_1000780b8;\n if (param_5 != 0) {\n dVar7 = 1.0;\n if (param_1 <= 1.0) {\n dVar7 = param_1;\n }\n dVar8 = 0.0;\n if (0.0 <= param_1) {\n dVar8 = dVar7 + dVar7;\n }\n dVar7 = 2.0;\n if (dVar8 <= 2.0) {\n dVar7 = dVar8;\n }\n dVar11 = 0.0;\n if (0.0 <= dVar8) {\n dVar11 = dVar7;\n }\n IVar6 = param_5;\n if (2.220446049250313e-16 < dVar11) {\n dVar11 = dVar11 * 0.5;\n dVar7 = dVar11;\n _pow(dVar11,0x4001333333333333);\n Line truncated
|
||||||
可执行文件
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
set -euo pipefail
|
||||||
|
cd "${0:A:h:h}"
|
||||||
|
output_dir=$(mktemp -d /tmp/paintfcp-tests.XXXXXX)
|
||||||
|
trap 'rm -rf "$output_dir"' EXIT
|
||||||
|
plugin_dir="$PWD/beatygril/beatygril/Plugin"
|
||||||
|
common=(-fobjc-arc -fmodules -Wall -Wextra -Wno-unused-parameter -DCI_SILENCE_GL_DEPRECATION -mmacosx-version-min=13.0 -I "$plugin_dir" -framework Foundation -framework CoreImage -framework CoreGraphics -framework CoreMedia -framework Metal -framework Vision -framework ImageIO)
|
||||||
|
xcrun clang "${common[@]}" "$plugin_dir/PGBeautyRenderer.m" tests/beauty_render_tests.m -o "$output_dir/beauty-tests"
|
||||||
|
"$output_dir/beauty-tests"
|
||||||
|
xcrun clang "${common[@]}" "$plugin_dir/PGFaceProcessor.m" tests/face_tests.m -o "$output_dir/face-tests"
|
||||||
|
"$output_dir/face-tests" "$PWD/tests/fixtures/astronaut.png"
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <CoreImage/CoreImage.h>
|
||||||
|
#import <Metal/Metal.h>
|
||||||
|
#import "PGBeautyRenderer.h"
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
static const int W=96,H=64;
|
||||||
|
static int failures=0,checks=0;
|
||||||
|
static void Check(BOOL pass,NSString *message) {
|
||||||
|
checks++;
|
||||||
|
if (!pass) { failures++; fprintf(stderr,"FAIL: %s\n",message.UTF8String); }
|
||||||
|
}
|
||||||
|
static CIImage *Image(const float *pixels,CGRect extent) {
|
||||||
|
NSData *data=[NSData dataWithBytes:pixels length:W*H*4*sizeof(float)];
|
||||||
|
return [[CIImage imageWithBitmapData:data bytesPerRow:W*4*sizeof(float)
|
||||||
|
size:CGSizeMake(W,H) format:kCIFormatRGBAf colorSpace:nil]
|
||||||
|
imageByApplyingTransform:CGAffineTransformMakeTranslation(extent.origin.x,extent.origin.y)];
|
||||||
|
}
|
||||||
|
static float *Render(CIContext *context,CIImage *image,CGRect extent) {
|
||||||
|
float *p=calloc(W*H*4,sizeof(float));
|
||||||
|
if (image) [context render:image toBitmap:p rowBytes:W*4*sizeof(float)
|
||||||
|
bounds:extent format:kCIFormatRGBAf colorSpace:nil];
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
static float Difference(float *a,float *b,int x0,int x1) {
|
||||||
|
float sum=0;int n=0;
|
||||||
|
for(int y=8;y<H-8;y++) for(int x=x0;x<x1;x++) for(int c=0;c<3;c++) {
|
||||||
|
sum+=fabsf(a[(y*W+x)*4+c]-b[(y*W+x)*4+c]); n++;
|
||||||
|
}
|
||||||
|
return sum/MAX(1,n);
|
||||||
|
}
|
||||||
|
static float Texture(float *a,int x0,int x1) {
|
||||||
|
float sum=0;int n=0;
|
||||||
|
for(int y=8;y<H-8;y++) for(int x=x0+1;x<x1;x++) {
|
||||||
|
sum+=fabsf(a[(y*W+x)*4]-a[(y*W+x-1)*4]);n++;
|
||||||
|
}
|
||||||
|
return sum/MAX(1,n);
|
||||||
|
}
|
||||||
|
int main(void) { @autoreleasepool {
|
||||||
|
NSDictionary *options=@{kCIContextWorkingColorSpace:[NSNull null],kCIContextOutputColorSpace:[NSNull null]};
|
||||||
|
id<MTLDevice> device=MTLCreateSystemDefaultDevice();
|
||||||
|
CIContext *context=device ? [CIContext contextWithMTLDevice:device options:options] : [CIContext contextWithOptions:options];
|
||||||
|
PGBeautyRenderer *renderer=[PGBeautyRenderer new];
|
||||||
|
CGRect extent=CGRectMake(13,-9,W,H);
|
||||||
|
float *p=calloc(W*H*4,sizeof(float)),*mask=calloc(W*H*4,sizeof(float));
|
||||||
|
for(int y=0;y<H;y++) for(int x=0;x<W;x++) {
|
||||||
|
int i=(y*W+x)*4;float noise=((x+y)%2 ? 1 : -1)*0.045;
|
||||||
|
p[i]=0.56+noise;p[i+1]=0.40+noise;p[i+2]=0.31+noise;p[i+3]=1;
|
||||||
|
mask[i]=mask[i+1]=mask[i+2]=(x<W/2 ? 1:0);mask[i+3]=1;
|
||||||
|
}
|
||||||
|
CIImage *source=Image(p,extent),*half=Image(mask,extent);
|
||||||
|
CIImage *white=[[CIImage imageWithColor:[CIColor colorWithRed:1 green:1 blue:1 alpha:1]] imageByCroppingToRect:extent];
|
||||||
|
PGBeautySettings s=PGBeautyDefaults();NSError *error=nil;
|
||||||
|
CIImage *output=[renderer processImage:source settings:s skinMask:white teethMask:nil eyeMask:nil error:&error];
|
||||||
|
Check(output!=nil && error==nil,@"All custom kernels compile and construct a graph");
|
||||||
|
Check(CGRectEqualToRect(output.extent,extent),@"Nonzero/negative source origin and finite extent survive");
|
||||||
|
float *base=Render(context,source,extent),*neutral=Render(context,output,extent);
|
||||||
|
Check(Difference(base,neutral,4,W-4)<0.00001,@"Neutral controls produce identity");free(neutral);
|
||||||
|
s.midtoneDetail=1;s.maskEdgeProtect=0;s.featureProtection=0;
|
||||||
|
output=[renderer processImage:source settings:s skinMask:white teethMask:nil eyeMask:nil error:&error];
|
||||||
|
float *smooth=Render(context,output,extent);
|
||||||
|
Check(Texture(smooth,8,W-8)<Texture(base,8,W-8)*0.82,@"Midtone compression reduces fine texture");
|
||||||
|
s.mix=0;
|
||||||
|
float *bypass=Render(context,[renderer processImage:source settings:s skinMask:white teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
Check(Difference(base,bypass,4,W-4)<0.00001,@"Mix zero is exact bypass");free(bypass);
|
||||||
|
s=PGBeautyDefaults();s.skinBrightness=1;s.maskSoftness=0;s.maskEdgeProtect=0;
|
||||||
|
float *bright=Render(context,[renderer processImage:source settings:s skinMask:half teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
Check(Difference(base,bright,6,40)>0.08,@"Skin brightness has a substantial visible effect");
|
||||||
|
Check(Difference(base,bright,56,88)<0.00001,@"Skin-only color change respects the supplied mask");
|
||||||
|
s.invertMask=1;
|
||||||
|
float *inverse=Render(context,[renderer processImage:source settings:s skinMask:half teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
Check(Difference(base,inverse,6,40)<0.00001 && Difference(base,inverse,56,88)>0.08,@"Invert mask selects the complementary region");
|
||||||
|
free(bright);free(inverse);
|
||||||
|
s=PGBeautyDefaults();s.showMask=1;s.maskSoftness=0;s.maskEdgeProtect=0;
|
||||||
|
float *preview=Render(context,[renderer processImage:source settings:s skinMask:half teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
Check(preview[(32*W+20)*4]>0.99 && preview[(32*W+72)*4]<0.01,@"Mask preview exposes actual coverage");free(preview);
|
||||||
|
s=PGBeautyDefaults();s.highPassDetail=1;
|
||||||
|
float *global=Render(context,[renderer processImage:source settings:s skinMask:nil teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
Check(Texture(global,8,W-8)<Texture(base,8,W-8)*0.72,@"Global high-frequency compression works without a skin mask");free(global);
|
||||||
|
s=PGBeautyDefaults();s.teethWhitening=1;
|
||||||
|
float *whiten=Render(context,[renderer processImage:source settings:s skinMask:nil teethMask:half eyeMask:nil error:&error],extent);
|
||||||
|
Check(whiten[(32*W+20)*4+2]>base[(32*W+20)*4+2]+0.035,@"Teeth whitening neutralizes yellow and lifts blue");
|
||||||
|
Check(Difference(base,whiten,56,88)<0.00001,@"Teeth whitening is spatially confined");free(whiten);
|
||||||
|
s=PGBeautyDefaults();s.eyeBrightening=1;
|
||||||
|
float *eyes=Render(context,[renderer processImage:source settings:s skinMask:nil teethMask:nil eyeMask:half error:&error],extent);
|
||||||
|
Check(Difference(base,eyes,6,40)>0.04 && Difference(base,eyes,56,88)<0.00001,@"Eye brightening is visible and spatially confined");free(eyes);
|
||||||
|
for(int preset=1;preset<=6;preset++) {
|
||||||
|
s=PGBeautyDefaults();s.skinTonePreset=preset;s.maskSoftness=0;s.maskEdgeProtect=0;
|
||||||
|
float *look=Render(context,[renderer processImage:source settings:s skinMask:half teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
Check(Difference(base,look,6,40)>0.002 && Difference(base,look,56,88)<0.00001,
|
||||||
|
[NSString stringWithFormat:@"Independent skin preset %d changes only skin",preset]);free(look);
|
||||||
|
}
|
||||||
|
s=PGBeautyDefaults();s.skinHue=30;s.maskSoftness=0;s.maskEdgeProtect=0;
|
||||||
|
float *hue=Render(context,[renderer processImage:source settings:s skinMask:half teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
Check(Difference(base,hue,6,40)>0.025 && Difference(base,hue,56,88)<0.00001,@"Hue rotates selected skin without changing the rest");free(hue);
|
||||||
|
s.skinHue=0;s.skinSaturation=-100;
|
||||||
|
float *desaturated=Render(context,[renderer processImage:source settings:s skinMask:half teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
int center=(32*W+20)*4;
|
||||||
|
Check(fabsf(desaturated[center]-desaturated[center+1])<0.0002 && fabsf(desaturated[center+1]-desaturated[center+2])<0.0002,@"Minus 100 saturation produces neutral gray skin");free(desaturated);
|
||||||
|
s=PGBeautyDefaults();s.sharpness=1;
|
||||||
|
float *sharp=Render(context,[renderer processImage:source settings:s skinMask:nil teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
Check(Texture(sharp,8,W-8)>Texture(base,8,W-8)*1.05,@"Sharpening increases local edge contrast");free(sharp);
|
||||||
|
s=PGBeautyDefaults();s.midtoneDetail=1;s.maskSoftness=0;s.maskEdgeProtect=0;s.featureProtection=1;
|
||||||
|
float *protected=Render(context,[renderer processImage:source settings:s skinMask:white teethMask:nil eyeMask:half error:&error],extent);
|
||||||
|
Check(Difference(base,protected,6,40)<0.00001 && Texture(protected,56,88)<Texture(base,56,88)*0.82,@"Feature protection retains eye detail while smoothing nearby skin");free(protected);
|
||||||
|
s=PGBeautyDefaults();s.showMask=1;s.maskSoftness=0;s.maskEdgeProtect=0;s.maskThreshold=255;
|
||||||
|
float *strictMask=Render(context,[renderer processImage:source settings:s skinMask:half teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
s.maskThreshold=0;
|
||||||
|
float *looseMask=Render(context,[renderer processImage:source settings:s skinMask:half teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
Check(looseMask[(32*W+20)*4]>=strictMask[(32*W+20)*4] && looseMask[(32*W+72)*4]==0,@"Mask threshold keeps zero coverage empty");free(strictMask);free(looseMask);
|
||||||
|
CIImage *grayMask=[[CIImage imageWithColor:[CIColor colorWithRed:0.5 green:0.5 blue:0.5 alpha:1]] imageByCroppingToRect:extent];
|
||||||
|
s.maskThreshold=220;
|
||||||
|
float *highThreshold=Render(context,[renderer processImage:source settings:s skinMask:grayMask teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
s.maskThreshold=60;
|
||||||
|
float *lowThreshold=Render(context,[renderer processImage:source settings:s skinMask:grayMask teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
Check(highThreshold[(32*W+20)*4]<0.01 && lowThreshold[(32*W+20)*4]>0.99,@"Mask threshold materially changes intermediate confidence coverage");free(highThreshold);free(lowThreshold);
|
||||||
|
// Chroma-only speckle should be reduced while its luminance stays stable.
|
||||||
|
float *colorNoise=calloc(W*H*4,sizeof(float));
|
||||||
|
for(int y=0;y<H;y++)for(int x=0;x<W;x++){
|
||||||
|
int i=(y*W+x)*4;float n=((x+y)%2 ? 1:-1)*0.055;
|
||||||
|
colorNoise[i]=0.56+n;colorNoise[i+1]=0.40-n*0.299/0.587;colorNoise[i+2]=0.31;colorNoise[i+3]=1;
|
||||||
|
}
|
||||||
|
CIImage *speckle=Image(colorNoise,extent);s=PGBeautyDefaults();s.skinEvenTone=1;s.maskEdgeProtect=0;
|
||||||
|
float *evenColor=Render(context,[renderer processImage:speckle settings:s skinMask:white teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
float before=Texture(colorNoise,8,W-8),after=Texture(evenColor,8,W-8);
|
||||||
|
Check(after<before*0.5,@"Even tone reduces chromatic skin mottling");
|
||||||
|
float oldY=colorNoise[center]*0.299+colorNoise[center+1]*0.587+colorNoise[center+2]*0.114;
|
||||||
|
float newY=evenColor[center]*0.299+evenColor[center+1]*0.587+evenColor[center+2]*0.114;
|
||||||
|
Check(fabsf(oldY-newY)<0.001,@"Even tone preserves local luminance");free(colorNoise);free(evenColor);
|
||||||
|
// Mask channel selection remains dormant until the preview checkbox is enabled.
|
||||||
|
CIImage *quarterMask=[[CIImage imageWithColor:[CIColor colorWithRed:0.25 green:0.25 blue:0.25 alpha:1]] imageByCroppingToRect:extent];
|
||||||
|
CIImage *eyeCoverage=[[CIImage imageWithColor:[CIColor colorWithRed:0.8 green:0.8 blue:0.8 alpha:1]] imageByCroppingToRect:extent];
|
||||||
|
for(int channel=0;channel<3;channel++){
|
||||||
|
s=PGBeautyDefaults();s.debugChannel=channel;
|
||||||
|
float *normal=Render(context,[renderer processImage:source settings:s skinMask:half teethMask:quarterMask eyeMask:eyeCoverage error:&error],extent);
|
||||||
|
Check(Difference(base,normal,4,W-4)<0.00001,[NSString stringWithFormat:@"Channel %d does not enable preview by itself",channel]);free(normal);
|
||||||
|
s.showMask=1;s.maskSoftness=0;s.maskEdgeProtect=0;s.featureProtection=0;
|
||||||
|
for(int invert=0;invert<2;invert++){
|
||||||
|
s.invertMask=invert;
|
||||||
|
float *shown=Render(context,[renderer processImage:source settings:s skinMask:half teethMask:quarterMask eyeMask:eyeCoverage error:&error],extent);
|
||||||
|
float expected=channel==0 ? 1 : (channel==1 ? 0.25 : 0.8);
|
||||||
|
if(invert)expected=1-expected;
|
||||||
|
Check(fabsf(shown[(32*W+20)*4]-expected)<0.0002,[NSString stringWithFormat:@"Preview channel %d uses its supplied mask (invert %d)",channel,invert]);free(shown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
float *looks[7]={0};
|
||||||
|
for(int preset=0;preset<=6;preset++){
|
||||||
|
s=PGBeautyDefaults();s.skinTonePreset=preset;s.maskSoftness=0;s.maskEdgeProtect=0;
|
||||||
|
looks[preset]=Render(context,[renderer processImage:source settings:s skinMask:half teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
}
|
||||||
|
Check(Difference(base,looks[0],4,W-4)<0.00001,@"Original color preset is exact identity");
|
||||||
|
Check(looks[2][center+2]-looks[2][center]>looks[3][center+2]-looks[3][center]+0.04,@"Cool white and warm white follow popup order");
|
||||||
|
Check(looks[4][center]-looks[4][center+1]>looks[1][center]-looks[1][center+1],@"Rosy white has stronger rosy chroma than natural white");
|
||||||
|
float bronzeY=looks[6][center]*0.299+looks[6][center+1]*0.587+looks[6][center+2]*0.114;
|
||||||
|
float wheatY=looks[5][center]*0.299+looks[5][center+1]*0.587+looks[5][center+2]*0.114;
|
||||||
|
float sourceY=base[center]*0.299+base[center+1]*0.587+base[center+2]*0.114;
|
||||||
|
Check(bronzeY<wheatY && bronzeY<sourceY-0.025 && looks[6][center]>looks[6][center+2],@"Bronze is a darker warm brown than wheat");
|
||||||
|
for(int preset=0;preset<=6;preset++)free(looks[preset]);
|
||||||
|
// The renderer's optional blend is linear once; a host that also blends must
|
||||||
|
// pass mix=1 and blend the complete processed image against the original.
|
||||||
|
s=PGBeautyDefaults();s.skinBrightness=1;s.skinHue=14;s.skinSaturation=20;s.maskSoftness=0;s.maskEdgeProtect=0;
|
||||||
|
float *fullEffect=Render(context,[renderer processImage:source settings:s skinMask:half teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
s.mix=0.5;
|
||||||
|
float *halfEffect=Render(context,[renderer processImage:source settings:s skinMask:half teethMask:nil eyeMask:nil error:&error],extent);
|
||||||
|
BOOL linearMix=YES;
|
||||||
|
for(int i=0;i<W*H*4;i++)if(fabsf(halfEffect[i]-(base[i]+fullEffect[i])*0.5f)>0.0002)linearMix=NO;
|
||||||
|
Check(linearMix,@"Combined color controls receive exactly one linear global blend");free(fullEffect);free(halfEffect);
|
||||||
|
// Every operation must preserve transparency rather than creating halos/opaque pixels.
|
||||||
|
for(int i=0;i<W*H;i++){p[i*4]*=0.4;p[i*4+1]*=0.4;p[i*4+2]*=0.4;p[i*4+3]=0.4;}
|
||||||
|
CIImage *transparent=Image(p,extent);s=PGBeautyDefaults();s.midtoneDetail=1;s.skinBrightness=1;s.teethWhitening=1;s.eyeBrightening=1;s.skinTonePreset=3;s.highPassDetail=1;
|
||||||
|
float *alpha=Render(context,[renderer processImage:transparent settings:s skinMask:white teethMask:half eyeMask:half error:&error],extent);
|
||||||
|
BOOL alphaOK=YES,finite=YES;
|
||||||
|
for(int i=0;i<W*H;i++){if(fabsf(alpha[i*4+3]-0.4f)>0.0002)alphaOK=NO;for(int c=0;c<4;c++)if(!isfinite(alpha[i*4+c]))finite=NO;}
|
||||||
|
Check(alphaOK,@"Combined beauty pipeline preserves fractional alpha");Check(finite,@"Combined beauty pipeline produces finite pixels");free(alpha);
|
||||||
|
// Explicit failure path for unbounded images.
|
||||||
|
CIImage *invalid=[renderer processImage:[CIImage imageWithColor:[CIColor whiteColor]] settings:s skinMask:nil teethMask:nil eyeMask:nil error:&error];
|
||||||
|
Check(invalid==nil && error!=nil,@"Unbounded input produces a useful error");
|
||||||
|
free(base);free(smooth);free(mask);free(p);
|
||||||
|
printf("Beauty renderer: %d checks, %d failures (%s).\n",checks,failures,device ? "Metal" : "default Core Image");
|
||||||
|
return failures ? 1:0;
|
||||||
|
}}
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <CoreImage/CoreImage.h>
|
||||||
|
#import <Vision/Vision.h>
|
||||||
|
#import <ImageIO/ImageIO.h>
|
||||||
|
#import "PGFaceProcessor.h"
|
||||||
|
|
||||||
|
// Injectable detections let geometry, selection and saved-frame behavior be
|
||||||
|
// tested independently of the separate public-domain NASA portrait fixture.
|
||||||
|
@interface PGFaceProcessor (TestDetection)
|
||||||
|
- (NSArray<VNFaceObservation *> *)observationsInImage:(CIImage *)image settings:(PGBeautySettings)settings error:(NSError **)error;
|
||||||
|
@end
|
||||||
|
@interface PGFaceFixture : PGFaceProcessor
|
||||||
|
@property (nonatomic, copy) NSArray<VNFaceObservation *> *detections;
|
||||||
|
@property (nonatomic) NSUInteger detectionCount;
|
||||||
|
@end
|
||||||
|
@implementation PGFaceFixture
|
||||||
|
- (NSArray<VNFaceObservation *> *)observationsInImage:(CIImage *)image settings:(PGBeautySettings)settings error:(NSError **)error {
|
||||||
|
self.detectionCount++;
|
||||||
|
return self.detections ?: @[];
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
||||||
|
static NSUInteger PGChecks = 0;
|
||||||
|
static void Check(BOOL condition, NSString *description) {
|
||||||
|
PGChecks++;
|
||||||
|
if (!condition) { fprintf(stderr, "FAIL: %s\n", description.UTF8String); exit(1); }
|
||||||
|
}
|
||||||
|
static VNFaceObservation *Face(CGRect box) {
|
||||||
|
return [VNFaceObservation faceObservationWithRequestRevision:VNDetectFaceLandmarksRequestRevision3 boundingBox:box roll:nil yaw:nil pitch:nil];
|
||||||
|
}
|
||||||
|
static NSData *Pixels(CIContext *context, CIImage *image) {
|
||||||
|
CGRect bounds = image.extent;
|
||||||
|
NSUInteger width = (NSUInteger)bounds.size.width, height = (NSUInteger)bounds.size.height;
|
||||||
|
NSMutableData *data = [NSMutableData dataWithLength:width * height * 4];
|
||||||
|
CGColorSpaceRef colorSpace = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
||||||
|
[context render:image toBitmap:data.mutableBytes rowBytes:width * 4 bounds:bounds format:kCIFormatRGBA8 colorSpace:colorSpace];
|
||||||
|
CGColorSpaceRelease(colorSpace);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
static unsigned char Channel(CIContext *context, CIImage *image, double x, double y, NSUInteger channel) {
|
||||||
|
unsigned char pixel[4] = {0};
|
||||||
|
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
||||||
|
[context render:image toBitmap:pixel rowBytes:4 bounds:CGRectMake(x, y, 1, 1) format:kCIFormatRGBA8 colorSpace:space];
|
||||||
|
CGColorSpaceRelease(space);
|
||||||
|
return pixel[channel];
|
||||||
|
}
|
||||||
|
static CIImage *Gradient(void) {
|
||||||
|
NSMutableData *data = [NSMutableData dataWithLength:128 * 128 * 4];
|
||||||
|
unsigned char *p = data.mutableBytes;
|
||||||
|
for (NSUInteger y = 0; y < 128; y++) for (NSUInteger x = 0; x < 128; x++) {
|
||||||
|
NSUInteger index = 4 * (y * 128 + x);
|
||||||
|
p[index] = x * 2; p[index + 1] = y * 2;
|
||||||
|
p[index + 2] = ((x / 8 + y / 8) % 2) * 220 + 15; p[index + 3] = 255;
|
||||||
|
}
|
||||||
|
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
||||||
|
CIImage *image = [CIImage imageWithBitmapData:data bytesPerRow:128 * 4 size:CGSizeMake(128, 128) format:kCIFormatRGBA8 colorSpace:space];
|
||||||
|
CGColorSpaceRelease(space);
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, const char *argv[]) {
|
||||||
|
@autoreleasepool {
|
||||||
|
CIContext *context = [CIContext contextWithOptions:@{kCIContextCacheIntermediates:@NO}];
|
||||||
|
CIImage *image = Gradient();
|
||||||
|
PGBeautySettings settings = PGBeautyDefaults();
|
||||||
|
PGFaceFixture *processor = [[PGFaceFixture alloc] init];
|
||||||
|
VNFaceObservation *left = Face(CGRectMake(0.08, 0.18, 0.36, 0.62));
|
||||||
|
VNFaceObservation *right = Face(CGRectMake(0.64, 0.23, 0.28, 0.53));
|
||||||
|
processor.detections = @[left, right];
|
||||||
|
NSError *error = nil;
|
||||||
|
PGFaceFrame *frame = [processor processImage:image settings:settings time:kCMTimeZero error:&error];
|
||||||
|
Check(frame != nil && error == nil, @"kernels initialize and process finite input");
|
||||||
|
Check(frame.faces.count == 2, @"multi-face mode retains both detections");
|
||||||
|
Check([Pixels(context, frame.image) isEqualToData:Pixels(context, image)], @"zero geometry controls preserve original pixels");
|
||||||
|
Check(Channel(context, frame.skinMask, 1, 1, 0) == 0, @"skin mask excludes background");
|
||||||
|
Check(Channel(context, frame.skinMask, 18, 54, 0) > 150, @"skin mask covers cheek");
|
||||||
|
Check(Channel(context, frame.eyeMask, 23, 71, 0) > 40, @"eye mask includes estimated eye region");
|
||||||
|
Check(Channel(context, frame.teethMask, 33, 42, 0) > 20, @"teeth mask includes inner mouth region");
|
||||||
|
PGBeautySettings unprotected = settings; unprotected.featureProtection = 0;
|
||||||
|
PGFaceFrame *withoutProtection = [processor processImage:image settings:unprotected time:kCMTimeZero error:&error];
|
||||||
|
Check(Channel(context, frame.skinMask, 23, 71, 0) < Channel(context, withoutProtection.skinMask, 23, 71, 0), @"feature protection removes eye from skin smoothing");
|
||||||
|
|
||||||
|
settings.singleFace = 1; settings.selectionX = 0.78; settings.selectionY = 0.45;
|
||||||
|
frame = [processor processImage:image settings:settings time:kCMTimeZero error:&error];
|
||||||
|
Check(frame.faces.count == 1 && frame.faces.firstObject.boundingBox.origin.x > 0.5, @"single-face selection uses normalized bottom-left point");
|
||||||
|
settings.selectionX = 0.25; settings.selectionY = 0.5;
|
||||||
|
settings.trackingEnabled = 1; settings.faceJumpGuardRange = 0.5;
|
||||||
|
CMTime first = CMTimeMake(1, 24), second = CMTimeMake(2, 24);
|
||||||
|
Check([processor analyzeImage:image settings:settings time:first error:&error], @"analysis stores first precise frame");
|
||||||
|
processor.detections = @[right];
|
||||||
|
Check([processor analyzeImage:image settings:settings time:second error:&error], @"analysis stores guarded missing-target frame");
|
||||||
|
NSUInteger detectionsBeforeRender = processor.detectionCount;
|
||||||
|
PGFaceFrame *secondFrame = [processor processImage:image settings:settings time:second error:&error];
|
||||||
|
PGFaceFrame *firstFrame = [processor processImage:image settings:settings time:CMTimeMake(2, 48) error:&error];
|
||||||
|
Check(secondFrame.faces.count == 0, @"jump guard rejects distant replacement face");
|
||||||
|
Check(firstFrame.faces.count == 1 && firstFrame.faces.firstObject.boundingBox.origin.x < 0.5, @"reverse render retrieves exact earlier frame");
|
||||||
|
Check(processor.detectionCount == detectionsBeforeRender, @"saved frame lookup does not re-run detection");
|
||||||
|
Check(Channel(context, secondFrame.skinMask, 33, 43, 0) == 0, @"rejected target has black mask");
|
||||||
|
Check([processor analyzeImage:image settings:settings time:CMTimeMake(3, 24) error:&error], @"analysis continues after a lost target");
|
||||||
|
Check([processor processImage:image settings:settings time:CMTimeMake(3, 24) error:&error].faces.count == 0, @"jump guard keeps last valid identity across a missed frame");
|
||||||
|
Check([processor processImage:image settings:settings time:CMTimeMake(7, 48) error:&error].faces.count == 0, @"unsaved frame detects current image with deterministic analyzed identity guard");
|
||||||
|
Check([processor analyzeImage:image settings:settings time:CMTimeMake(3, 1) error:&error], @"analysis tolerates a seek across lost detections");
|
||||||
|
Check([processor processImage:image settings:settings time:CMTimeMake(3, 1) error:&error].faces.count == 0, @"jump guard does not silently reacquire a stranger after a long gap");
|
||||||
|
NSData *archive = [processor trackingDataForTarget:0];
|
||||||
|
Check(archive.length > 0, @"tracking is persistable");
|
||||||
|
PGFaceFixture *restored = [[PGFaceFixture alloc] init];
|
||||||
|
[restored setTrackingData:archive forTarget:0];
|
||||||
|
PGFaceFrame *restoredFrame = [restored processImage:image settings:settings time:first error:&error];
|
||||||
|
Check(restoredFrame.faces.count == 1 && restored.detectionCount == 0, @"restored target serves exact frame without detections");
|
||||||
|
Check([Pixels(context, firstFrame.skinMask) isEqualToData:Pixels(context, restoredFrame.skinMask)], @"restored geometry reproduces feature masks");
|
||||||
|
Check([restored trackingDataForTarget:1] == nil && [restored trackingDataForTarget:2] == nil, @"three target stores are independent");
|
||||||
|
[restored setTrackingData:archive forTarget:2];
|
||||||
|
settings.targetIndex = 2;
|
||||||
|
Check([restored processImage:image settings:settings time:first error:&error].faces.count == 1, @"third target stores and restores separately");
|
||||||
|
settings.targetIndex = 0;
|
||||||
|
[restored processImage:image settings:settings time:CMTimeMake(4, 24) error:&error];
|
||||||
|
Check(restored.detectionCount == 1, @"missing exact time uses stateless detection");
|
||||||
|
[restored setTrackingData:[@"invalid archive" dataUsingEncoding:NSUTF8StringEncoding] forTarget:0];
|
||||||
|
Check([restored trackingDataForTarget:0] == nil, @"invalid archive is rejected without exception");
|
||||||
|
[restored resetTracking];
|
||||||
|
Check([restored trackingDataForTarget:2] == nil, @"reset clears all targets");
|
||||||
|
error = nil;
|
||||||
|
Check(![restored analyzeImage:image settings:settings time:kCMTimeInvalid error:&error] && error != nil, @"analysis rejects invalid times");
|
||||||
|
|
||||||
|
processor.detections = @[Face(CGRectMake(0.2, 0.15, 0.6, 0.7))];
|
||||||
|
settings = PGBeautyDefaults();
|
||||||
|
NSData *base = Pixels(context, image);
|
||||||
|
NSString *names[] = {@"thin", @"big eyes", @"short", @"narrow", @"chin", @"V", @"jaw", @"cheekbone", @"small head"};
|
||||||
|
for (NSUInteger i = 0; i < 9; i++) {
|
||||||
|
PGBeautySettings changed = settings;
|
||||||
|
float *controls[] = {&changed.faceThin, &changed.faceBigEye, &changed.faceShort, &changed.faceNarrow, &changed.faceChin, &changed.faceV, &changed.faceJaw, &changed.faceCheekbone, &changed.faceSmallHead};
|
||||||
|
*controls[i] = 1;
|
||||||
|
PGFaceFrame *warped = [processor processImage:image settings:changed time:kCMTimeZero error:&error];
|
||||||
|
Check(warped != nil && ![Pixels(context, warped.image) isEqualToData:base], [@"geometry control changes pixels: " stringByAppendingString:names[i]]);
|
||||||
|
Check(CGRectEqualToRect(warped.image.extent, image.extent), @"warp preserves image extent");
|
||||||
|
}
|
||||||
|
settings.faceShort = 0.8;
|
||||||
|
NSData *positive = Pixels(context, [processor processImage:image settings:settings time:kCMTimeZero error:&error].image);
|
||||||
|
settings.faceShort = -0.8;
|
||||||
|
Check(![positive isEqualToData:Pixels(context, [processor processImage:image settings:settings time:kCMTimeZero error:&error].image)], @"signed short-face control has distinct directions");
|
||||||
|
settings = PGBeautyDefaults(); settings.showFaceBoxes = 1;
|
||||||
|
frame = [processor processImage:image settings:settings time:kCMTimeZero error:&error];
|
||||||
|
Check(Channel(context, frame.overlayImage, 26, 64, 3) > 0, @"face outlines provide separate final-composite overlay");
|
||||||
|
CIImage *offset = [image imageByApplyingTransform:CGAffineTransformMakeTranslation(30, 40)];
|
||||||
|
frame = [processor processImage:offset settings:settings time:kCMTimeZero error:&error];
|
||||||
|
Check(CGRectEqualToRect(frame.image.extent, offset.extent), @"nonzero image origins survive masks and warp");
|
||||||
|
|
||||||
|
PGFaceProcessor *vision = [[PGFaceProcessor alloc] init];
|
||||||
|
CIImage *blank = [[CIImage imageWithColor:CIColor.grayColor] imageByCroppingToRect:CGRectMake(0, 0, 128, 128)];
|
||||||
|
frame = [vision processImage:blank settings:PGBeautyDefaults() time:kCMTimeZero error:&error];
|
||||||
|
Check(frame != nil && frame.faces.count == 0, @"real Vision request accepts blank image without false faces");
|
||||||
|
|
||||||
|
NSString *fixturePath = argc > 1 ? @(argv[1]) : @"tests/fixtures/astronaut.png";
|
||||||
|
CIImage *portrait = [CIImage imageWithContentsOfURL:[NSURL fileURLWithPath:fixturePath]];
|
||||||
|
Check(portrait != nil, @"public-domain NASA portrait fixture opens");
|
||||||
|
settings = PGBeautyDefaults();
|
||||||
|
PGFaceFrame *portraitFrame = [vision processImage:portrait settings:settings time:kCMTimeZero error:&error];
|
||||||
|
Check(portraitFrame.faces.count > 0, @"real Vision detects astronaut portrait");
|
||||||
|
Check(CGRectEqualToRect(portraitFrame.skinMask.extent, portrait.extent) && CGRectEqualToRect(portraitFrame.teethMask.extent, portrait.extent) && CGRectEqualToRect(portraitFrame.eyeMask.extent, portrait.extent), @"all real portrait masks have finite input extent");
|
||||||
|
VNFaceObservation *actual = portraitFrame.faces.firstObject;
|
||||||
|
Check(actual.landmarks.allPoints.pointCount > 30 && actual.landmarks.leftEye.pointCount > 3, @"real Vision returns detailed facial landmarks");
|
||||||
|
const CGPoint *eyePoints = actual.landmarks.leftEye.normalizedPoints;
|
||||||
|
CGPoint eye = CGPointZero;
|
||||||
|
for (NSUInteger i = 0; i < actual.landmarks.leftEye.pointCount; i++) { eye.x += eyePoints[i].x; eye.y += eyePoints[i].y; }
|
||||||
|
eye.x /= actual.landmarks.leftEye.pointCount; eye.y /= actual.landmarks.leftEye.pointCount;
|
||||||
|
CGRect actualBox = actual.boundingBox;
|
||||||
|
eye.x = (actualBox.origin.x + eye.x * actualBox.size.width) * portrait.extent.size.width;
|
||||||
|
eye.y = (actualBox.origin.y + eye.y * actualBox.size.height) * portrait.extent.size.height;
|
||||||
|
Check(Channel(context, portraitFrame.eyeMask, floor(eye.x), floor(eye.y), 0) > 80, @"landmark-derived eye mask lands on real eye");
|
||||||
|
Check(Channel(context, portraitFrame.skinMask, floor(eye.x), floor(eye.y), 0) < 100, @"real facial feature mask excludes eye from smoothing");
|
||||||
|
settings.faceThin = 0.4; settings.faceBigEye = 0.25; settings.faceChin = 0.15; settings.faceSmallHead = 0.15;
|
||||||
|
settings.faceSmallHeadRange = 0.6; settings.showFaceBoxes = 1;
|
||||||
|
PGFaceFrame *beautified = [vision processImage:portrait settings:settings time:kCMTimeZero error:&error];
|
||||||
|
Check(beautified != nil && ![Pixels(context, beautified.image) isEqualToData:Pixels(context, portrait)], @"real face geometry changes portrait pixels");
|
||||||
|
Check(abs((int)Channel(context, beautified.image, 5, 300, 0) - (int)Channel(context, portrait, 5, 300, 0)) <= 1, @"face warp preserves distant portrait background pixels");
|
||||||
|
settings.trackingEnabled = 1; settings.singleFace = 1;
|
||||||
|
settings.selectionX = CGRectGetMidX(actualBox); settings.selectionY = CGRectGetMidY(actualBox);
|
||||||
|
Check([vision analyzeImage:portrait settings:settings time:first error:&error], @"real landmarks analyze into persistent geometry");
|
||||||
|
NSData *realArchive = [vision trackingDataForTarget:0];
|
||||||
|
PGFaceFrame *analyzed = [vision processImage:portrait settings:settings time:first error:&error];
|
||||||
|
PGFaceProcessor *realRestored = [[PGFaceProcessor alloc] init];
|
||||||
|
[realRestored setTrackingData:realArchive forTarget:0];
|
||||||
|
PGFaceFrame *restoredPortrait = [realRestored processImage:portrait settings:settings time:first error:&error];
|
||||||
|
Check(restoredPortrait.faces.count == analyzed.faces.count && [Pixels(context, restoredPortrait.image) isEqualToData:Pixels(context, analyzed.image)], @"real face warp survives persistence byte-for-byte");
|
||||||
|
Check([Pixels(context, restoredPortrait.eyeMask) isEqualToData:Pixels(context, analyzed.eyeMask)], @"real landmark eye mask survives persistence byte-for-byte");
|
||||||
|
Check([Pixels(context, restoredPortrait.teethMask) isEqualToData:Pixels(context, analyzed.teethMask)], @"real landmark teeth mask survives persistence byte-for-byte");
|
||||||
|
if (argc > 2) {
|
||||||
|
CGFloat width = portrait.extent.size.width, height = portrait.extent.size.height;
|
||||||
|
CIImage *after = [[beautified.overlayImage imageByCompositingOverImage:beautified.image] imageByCroppingToRect:portrait.extent];
|
||||||
|
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
||||||
|
CGContextRef canvas = CGBitmapContextCreate(NULL, width * 3, height, 8, width * 3 * 4, space, (CGBitmapInfo)kCGImageAlphaPremultipliedLast);
|
||||||
|
NSArray *panels = @[portrait, after, portraitFrame.skinMask];
|
||||||
|
for (NSUInteger panel = 0; panel < panels.count; panel++) {
|
||||||
|
CGImageRef content = [context createCGImage:panels[panel] fromRect:portrait.extent];
|
||||||
|
CGContextDrawImage(canvas, CGRectMake(panel * width, 0, width, height), content);
|
||||||
|
CGImageRelease(content);
|
||||||
|
}
|
||||||
|
CGImageRef preview = CGBitmapContextCreateImage(canvas);
|
||||||
|
CGImageDestinationRef destination = CGImageDestinationCreateWithURL((__bridge CFURLRef)[NSURL fileURLWithPath:@(argv[2])], CFSTR("public.png"), 1, NULL);
|
||||||
|
Check(destination != NULL && preview != NULL, @"portrait comparison preview allocates");
|
||||||
|
CGImageDestinationAddImage(destination, preview, NULL);
|
||||||
|
Check(CGImageDestinationFinalize(destination), @"portrait comparison preview writes PNG");
|
||||||
|
CFRelease(destination); CGImageRelease(preview);
|
||||||
|
CGContextRelease(canvas); CGColorSpaceRelease(space);
|
||||||
|
}
|
||||||
|
printf("Face processing: %lu checks passed\n", (unsigned long)PGChecks);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
astronaut.png — NASA photograph of astronaut Eileen Collins.
|
||||||
|
Obtained from the scikit-image v0.24.0 sample-data distribution:
|
||||||
|
https://raw.githubusercontent.com/scikit-image/scikit-image/v0.24.0/skimage/data/astronaut.png
|
||||||
|
The upstream project states that this image has no known copyright restrictions and is in the public domain:
|
||||||
|
https://scikit-image.org/docs/stable/api/skimage.data.html#skimage.data.astronaut
|
||||||
|
Used solely as a local image-processing regression fixture; no endorsement implied.
|
||||||
二进制
二进制文件未显示。
|
之后 宽度: | 高度: | 大小: 773 KiB |
在新工单中引用
屏蔽一个用户