Files
2026-07-13 13:33:03 +08:00

507 lines
19 KiB
Plaintext

//
// SanaDiffusionSession.mm
// MNNLLMiOS
//
// Created by 游薪渝(揽清) on 2025/01/19.
// Copyright © 2025 MNN. All rights reserved.
//
#import "SanaDiffusionSession.h"
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#include <MNN/diffusion/diffusion.hpp>
#include <MNN/diffusion/sana_llm.hpp>
#include <MNN/expr/Module.hpp>
#include <MNN/expr/ExprCreator.hpp>
#include <MNN/expr/Executor.hpp>
#include <MNN/expr/ExecutorScope.hpp>
#include <MNN/Interpreter.hpp>
#include <MNN/AutoTime.hpp>
#include <random>
#include <algorithm>
#include <fstream>
using namespace MNN;
using namespace MNN::Express;
using namespace MNN::Transformer;
using namespace MNN::DIFFUSION;
using namespace CV;
#pragma mark - Private Interface
@implementation SanaDiffusionSession {
/// SanaLlm instance for processing text prompts to embeddings (from sana_llm.hpp).
std::unique_ptr<SanaLlm> mSanaLlm;
/// Diffusion model instance using SANA_DIFFUSION type.
std::shared_ptr<Diffusion> mDiffusion;
/// Path to the model directory.
NSString *mModelPath;
/// Memory mode for Diffusion model (0 = default).
int mMemoryMode;
/// Flag indicating whether the model has been loaded.
BOOL _isModelLoaded;
/// Flag indicating whether a style transfer is in progress.
BOOL _isProcessing;
}
#pragma mark - Class Methods
+ (NSString *)defaultGhibliPrompt {
return @"Convert to a Ghibli-style illustration: soft contrast, warm tones, slight linework, keep the scene consistent.";
}
#pragma mark - Initialization
- (instancetype)initWithModelPath:(NSString *)path completion:(SanaCompletionHandler)completion {
self = [super init];
if (self) {
mModelPath = path;
mMemoryMode = 1;
_isModelLoaded = NO;
_isProcessing = NO;
// Load model asynchronously on background thread
// Threading strategy: All model loading and inference on background thread
// to keep UI responsive. Accept UIApplication warning for better UX.
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// Step 1: Load LLM on background thread
BOOL llmSuccess = [self loadLLMModel];
if (!llmSuccess) {
dispatch_async(dispatch_get_main_queue(), ^{
self->_isModelLoaded = NO;
if (completion) {
completion(NO);
}
});
return;
}
// Step 2: Load Diffusion on background thread
BOOL diffusionSuccess = [self loadDiffusionModel];
dispatch_async(dispatch_get_main_queue(), ^{
self->_isModelLoaded = diffusionSuccess;
if (completion) {
completion(diffusionSuccess);
}
});
});
}
return self;
}
#pragma mark - Model Loading
/// Loads the SanaLlm model from disk using sana_llm.hpp. Can be called from background thread.
/// @return YES if SanaLlm loaded successfully, NO otherwise.
- (BOOL)loadLLMModel {
@try {
NSLog(@"SanaDiffusionSession: Starting SanaLlm loading from %@", mModelPath);
// Load SanaLlm using sana_llm.hpp - it handles LLM and meta_queries loading internally
NSString *llmPath = [mModelPath stringByAppendingPathComponent:@"llm"];
NSLog(@"SanaDiffusionSession: Loading SanaLlm from %@", llmPath);
mSanaLlm = std::make_unique<SanaLlm>([llmPath UTF8String]);
if (!mSanaLlm) {
NSLog(@"SanaDiffusionSession: Failed to create SanaLlm");
return NO;
}
NSLog(@"SanaDiffusionSession: SanaLlm loading complete");
return YES;
} @catch (NSException *exception) {
NSLog(@"SanaDiffusionSession: Exception during SanaLlm loading: %@", exception.reason);
return NO;
}
}
/// Loads the Diffusion model from disk. Must be called from main thread (Metal requirement).
/// @return YES if Diffusion loaded successfully, NO otherwise.
- (BOOL)loadDiffusionModel {
@try {
// Load Diffusion model with SANA_DIFFUSION type
// Note: Must be on main thread because Metal backend calls [UIApplication applicationState]
NSLog(@"SanaDiffusionSession: Loading Sana Diffusion model");
Diffusion* rawDiffusion = Diffusion::createDiffusion(
[mModelPath UTF8String],
DiffusionModelType::SANA_DIFFUSION,
MNNForwardType::MNN_FORWARD_METAL,
mMemoryMode
);
if (!rawDiffusion) {
NSLog(@"SanaDiffusionSession: Failed to create Diffusion");
return NO;
}
mDiffusion = std::shared_ptr<Diffusion>(rawDiffusion);
if (!mDiffusion->load()) {
NSLog(@"SanaDiffusionSession: Failed to load Diffusion model");
return NO;
}
NSLog(@"SanaDiffusionSession: Model loading complete");
return YES;
} @catch (NSException *exception) {
NSLog(@"SanaDiffusionSession: Exception during Diffusion loading: %@", exception.reason);
return NO;
}
}
#pragma mark - LLM Processing
/// Processes a single text prompt through SanaLlm to generate embeddings for diffusion.
/// Uses sana_llm.hpp's SanaLlm::process() method directly.
///
/// @param prompt The text prompt to process.
/// @return VARP containing the processed embeddings [1, 256, hidden_size], or nullptr on failure.
- (VARP)processSinglePrompt:(NSString *)prompt {
if (!mSanaLlm) {
NSLog(@"SanaDiffusionSession: ERROR - mSanaLlm is not initialized!");
return nullptr;
}
NSLog(@"SanaDiffusionSession: Processing prompt via SanaLlm: %@", prompt);
// Use SanaLlm::process() directly - it handles all the formatting, tokenization, and LLM forward
VARP result = mSanaLlm->process([prompt UTF8String]);
if (result.get() == nullptr) {
NSLog(@"SanaDiffusionSession: ERROR - SanaLlm::process() returned nullptr");
return nullptr;
}
// Debug output
auto resultInfo = result->getInfo();
if (resultInfo) {
NSLog(@"SanaDiffusionSession: SanaLlm output shape: [%d, %d, %d]",
resultInfo->dim[0], resultInfo->dim[1], resultInfo->dim[2]);
}
return result;
}
#pragma mark - Style Transfer
- (void)runStyleTransferWithInputImage:(NSString *)inputImagePath
prompt:(NSString *)prompt
outputPath:(NSString *)outputPath
iterations:(int)iterations
seed:(int)seed
progressCallback:(SanaProgressHandler)progressCallback
completion:(SanaStyleTransferCompletion)completion {
// Validate model state
if (!_isModelLoaded) {
if (completion) {
completion(NO, @"Model not loaded", 0);
}
return;
}
if (_isProcessing) {
if (completion) {
completion(NO, @"Already processing", 0);
}
return;
}
_isProcessing = YES;
// Process on background thread to keep UI responsive.
// Note: This will trigger "[UIApplication applicationState] must be used from main thread only"
// warning from MNN Metal backend. This is expected and acceptable - see threading strategy
// comment in initWithModelPath:completion: for details.
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
@try {
// Normalize image orientation if needed
NSString *normalizedInputPath = [self normalizeImageOrientation:inputImagePath];
if (!normalizedInputPath) {
normalizedInputPath = inputImagePath;
}
// Record total start time
NSDate *totalStartTime = [NSDate date];
NSDate *llmStartTime = [NSDate date];
// Stage 1: Process prompt with LLM
dispatch_async(dispatch_get_main_queue(), ^{
if (progressCallback) {
progressCallback(5, @"Processing prompt...");
}
});
NSLog(@"SanaDiffusionSession: Processing prompt: %@", prompt);
VARP llmOutput = [self processSinglePrompt:prompt];
NSTimeInterval llmDuration = [[NSDate date] timeIntervalSinceDate:llmStartTime] * 1000;
NSLog(@"SanaDiffusionSession: LLM processing time: %.2f ms", llmDuration);
if (llmOutput.get() == nullptr) {
dispatch_async(dispatch_get_main_queue(), ^{
self->_isProcessing = NO;
if (completion) {
completion(NO, @"LLM processing failed", 0);
}
});
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
if (progressCallback) {
progressCallback(15, @"Running diffusion...");
}
});
// Stage 2: Run diffusion pipeline with LLM embeddings
NSDate *diffusionStartTime = [NSDate date];
// The pre-built MNN.framework's SanaDiffusion::run() does not invoke the
// C++ progressCallback during denoising. Work around this by using a
// dispatch timer on the main queue that fires while run() blocks the
// background thread, giving the user periodic progress updates.
__block int timerProgress = 15;
int progressPerTick = MAX(1, 80 / iterations); // 80% span / iterations
dispatch_source_t progressTimer = dispatch_source_create(
DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
// Fire every 2 seconds (roughly matches per-step duration)
dispatch_source_set_timer(progressTimer,
dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC),
2 * NSEC_PER_SEC, 0.5 * NSEC_PER_SEC);
dispatch_source_set_event_handler(progressTimer, ^{
timerProgress += progressPerTick;
if (timerProgress > 90) timerProgress = 90; // cap before completion
if (progressCallback) {
progressCallback(timerProgress, @"Running diffusion...");
}
});
dispatch_resume(progressTimer);
// Also keep the C++ callback in case a future framework rebuild enables it
auto diffusionProgressCallback = [progressCallback](int step) {
dispatch_async(dispatch_get_main_queue(), ^{
if (progressCallback) {
int progress = 15 + (step * 80 / 100);
progressCallback(progress, @"Running diffusion...");
}
});
};
// Debug: Print llmOutput tensor info
auto llmOutputInfo = llmOutput->getInfo();
if (llmOutputInfo) {
NSMutableString *dimStr = [NSMutableString stringWithString:@"["];
for (size_t i = 0; i < llmOutputInfo->dim.size(); i++) {
[dimStr appendFormat:@"%d", llmOutputInfo->dim[i]];
if (i < llmOutputInfo->dim.size() - 1) {
[dimStr appendString:@", "];
}
}
[dimStr appendString:@"]"];
NSLog(@"SanaDiffusionSession: llmOutput shape: %@, size: %zu, order: %d, type: %d",
dimStr,
llmOutputInfo->size,
(int)llmOutputInfo->order,
(int)llmOutputInfo->type.code);
} else {
NSLog(@"SanaDiffusionSession: llmOutput info is NULL!");
}
// Use new unified diffusion interface
bool success = self->mDiffusion->run(
llmOutput,
"img2img", // mode: image editing
[normalizedInputPath UTF8String], // input image path
[outputPath UTF8String], // output image path
512, // width
512, // height
iterations, // iterNum
seed, // randomSeed
false, // use_cfg
4.5f, // cfg_scale
diffusionProgressCallback
);
// Cancel the progress timer now that run() has returned
dispatch_source_cancel(progressTimer);
NSTimeInterval diffusionDuration = [[NSDate date] timeIntervalSinceDate:diffusionStartTime] * 1000;
NSTimeInterval totalDuration = [[NSDate date] timeIntervalSinceDate:totalStartTime] * 1000;
NSLog(@"SanaDiffusionSession: Diffusion time: %.2f ms", diffusionDuration);
NSLog(@"SanaDiffusionSession: Total time: %.2f ms", totalDuration);
// Clean up temporary normalized image if it was created
if (![normalizedInputPath isEqualToString:inputImagePath]) {
[[NSFileManager defaultManager] removeItemAtPath:normalizedInputPath error:nil];
}
// Save benchmark results to file
[self saveBenchmarkResult:@{
@"timestamp": [NSDate date],
@"iterations": @(iterations),
@"seed": @(seed),
@"llm_time_ms": @(llmDuration),
@"diffusion_time_ms": @(diffusionDuration),
@"total_time_ms": @(totalDuration),
@"success": @(success),
@"input_image": inputImagePath,
@"output_image": outputPath
}];
// Report completion on main thread
dispatch_async(dispatch_get_main_queue(), ^{
self->_isProcessing = NO;
// Note: Do NOT call progressCallback(100) here.
// The completion handler sets the final message directly,
// so calling progressCallback would cause a brief flash of
// an inconsistent message before completion replaces it.
if (completion) {
if (success) {
completion(YES, nil, totalDuration);
} else {
completion(NO, @"Diffusion processing failed", totalDuration);
}
}
});
} @catch (NSException *exception) {
dispatch_async(dispatch_get_main_queue(), ^{
self->_isProcessing = NO;
if (completion) {
completion(NO, [NSString stringWithFormat:@"Exception: %@", exception.reason], 0);
}
});
}
});
}
#pragma mark - Image Orientation Normalization
/// Normalizes the image orientation based on EXIF data.
/// @param imagePath Path to the input image.
/// @return Path to the normalized image, or nil if failed/not needed.
- (NSString *)normalizeImageOrientation:(NSString *)imagePath {
UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
if (!image) {
NSLog(@"SanaDiffusionSession: Failed to load image for normalization: %@", imagePath);
return nil;
}
// Check if orientation is already Up
if (image.imageOrientation == UIImageOrientationUp) {
return imagePath;
}
NSLog(@"SanaDiffusionSession: Normalizing image orientation from %ld", (long)image.imageOrientation);
// Drawing into a context redraws the image with "Up" orientation
UIGraphicsBeginImageContextWithOptions(image.size, NO, image.scale);
[image drawInRect:CGRectMake(0, 0, image.size.width, image.size.height)];
UIImage *normalizedImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
if (!normalizedImage) {
NSLog(@"SanaDiffusionSession: Failed to normalize image");
return nil;
}
// Save normalized image to a temporary path
NSString *tempPath = [NSTemporaryDirectory() stringByAppendingPathComponent:[NSString stringWithFormat:@"normalized_%@", [imagePath lastPathComponent]]];
NSData *data = UIImageJPEGRepresentation(normalizedImage, 0.9);
if ([data writeToFile:tempPath atomically:YES]) {
NSLog(@"SanaDiffusionSession: Normalized image saved to %@", tempPath);
return tempPath;
} else {
NSLog(@"SanaDiffusionSession: Failed to save normalized image");
return nil;
}
}
#pragma mark - Benchmark
/// Saves benchmark result to a JSON file in the Documents directory.
/// @param result Dictionary containing benchmark metrics.
- (void)saveBenchmarkResult:(NSDictionary *)result {
NSFileManager *fileManager = [NSFileManager defaultManager];
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *documentsDirectory = [paths firstObject];
NSString *benchmarkFile = [documentsDirectory stringByAppendingPathComponent:@"sana_diffusion_benchmark.json"];
// Read existing results or create new array
NSMutableArray *results = [NSMutableArray array];
if ([fileManager fileExistsAtPath:benchmarkFile]) {
NSData *existingData = [NSData dataWithContentsOfFile:benchmarkFile];
if (existingData) {
NSError *error = nil;
NSArray *existingResults = [NSJSONSerialization JSONObjectWithData:existingData options:0 error:&error];
if (existingResults && !error) {
[results addObjectsFromArray:existingResults];
}
}
}
// Format timestamp as string
NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
[formatter setDateFormat:@"yyyy-MM-dd HH:mm:ss"];
NSMutableDictionary *formattedResult = [result mutableCopy];
if (result[@"timestamp"]) {
formattedResult[@"timestamp"] = [formatter stringFromDate:result[@"timestamp"]];
}
// Add new result
[results addObject:formattedResult];
// Write back to file
NSError *writeError = nil;
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:results options:NSJSONWritingPrettyPrinted error:&writeError];
if (jsonData && !writeError) {
[jsonData writeToFile:benchmarkFile atomically:YES];
NSLog(@"SanaDiffusionSession: Benchmark saved to %@", benchmarkFile);
} else {
NSLog(@"SanaDiffusionSession: Failed to save benchmark: %@", writeError);
}
}
/// Returns the path to the benchmark results file.
+ (NSString *)benchmarkFilePath {
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *documentsDirectory = [paths firstObject];
return [documentsDirectory stringByAppendingPathComponent:@"sana_diffusion_benchmark.json"];
}
#pragma mark - Properties
- (BOOL)isModelLoaded {
return _isModelLoaded;
}
- (BOOL)isProcessing {
return _isProcessing;
}
#pragma mark - Memory Management
- (void)dealloc {
mSanaLlm.reset();
mDiffusion.reset();
NSLog(@"SanaDiffusionSession deallocated");
}
@end