How to save PNG file from NSImage (retina issues)
Asked Answered
C

6

31

I'm doing some operations on images, and after I'm done, I want to save the image as PNG on the disk. I'm doing the following:

+ (void)saveImage:(NSImage *)image atPath:(NSString *)path {
        
    [image lockFocus] ;
    NSBitmapImageRep *imageRepresentation = [[NSBitmapImageRep alloc] initWithFocusedViewRect:NSMakeRect(0.0, 0.0, image.size.width, image.size.height)] ;
    [image unlockFocus] ;
        
    NSData *data = [imageRepresentation representationUsingType:NSPNGFileType properties:nil];
    [data writeToFile:path atomically:YES];
}

This code is working, but there's a problem on Macs with Retina screens; if I print the NSBitmapImageRep object, I get a different size and pixels rectangle, and when my image is saved on the disk, it's twice the size:

$0 = 0x0000000100413890 NSBitmapImageRep 0x100413890 Size={300, 300} ColorSpace=sRGB IEC61966-2.1 colorspace BPS=8 BPP=32 Pixels=600x600 Alpha=YES Planar=NO Format=0 CurrentBacking=<CGImageRef: 0x100414830>

I tried to force the pixel size to not care about the Retina scale, as I want to preserve the original size:

imageRepresentation.pixelsWide = image.size.width;
imageRepresentation.pixelsHigh = image.size.height;

This time I get the right size when I print the NSBitmapImageRep object, but when I save the file, I still get the same issue:

$0 = 0x0000000100413890 NSBitmapImageRep 0x100413890 Size={300, 300} ColorSpace=sRGB IEC61966-2.1 colorspace BPS=8 BPP=32 Pixels=300x300 Alpha=YES Planar=NO Format=0 CurrentBacking=<CGImageRef: 0x100414830>

Any idea how to fix this, and preserve the original pixel size?

Coburn answered 6/7, 2013 at 21:8 Comment(0)
A
52

If you have an NSImage and want to save it as an image file to the filesystem, you should never use lockFocus! lockFocus creates a new image which is determined for getting shown an the screen and nothing else. Therefore lockFocus uses the properties of the screen: 72 dpi for normal screens and 144 dpi for retina screens. For what you want I propose the following code:

+ (void)saveImage:(NSImage *)image atPath:(NSString *)path {

    CGImageRef cgRef = [image CGImageForProposedRect:NULL
                                             context:nil
                                               hints:nil];

    NSBitmapImageRep *newRep = [[NSBitmapImageRep alloc] initWithCGImage:cgRef];
    [newRep setSize:[image size]]; // if you want the same resolution
    NSData *pngData = [newRep representationUsingType:NSPNGFileType properties:nil];
    [pngData writeToFile:path atomically:YES];
    [newRep autorelease];
}
Anabelle answered 7/7, 2013 at 8:58 Comment(6)
-[NSBitmapImageRep setSize:] seems to be available only since 10.10. Perhaps that's why when I tried your code on Mavericks, the image doesn't get resized? Although no exception is thrown... I'm getting an image of the same size as the original, no matter what size I pass.Cosset
@NicolasMiari I do see the size of newRep change to what it should be (targeting 10.9, but running on 10.10), but the file written to disk still contains a 2x image. Did you ever come up with a solution?Column
@NicolasMiari It works for me too, but I require an intermediate NSData, which it doesn't produce. So I'm writing it to a temp file then reading that in. It's not production code though, just a unit test. The most important thing for me is that it produces the same output for retina and non-retina screens (per-pixel-identical), which it looks like it doesn't do. There are small differences at boundaries between different colors...Column
-[NSBitmapImageRep setSize:] is inherited from NSImageRep and is available since 10.0Anabelle
Is not size difference coming from point to pixel conversation. If your mac screen is HD, points are using twice as much pixels, so, when you save it is twice the size as pixels. The drawing on screen is as points.Parang
This didn't work for me. The resulting image is still twice as wide and high as it should be.Casiecasilda
F
16

NSImage is resolution aware and uses a HiDPI graphics context when you lockFocus on a system with retina screen.
The image dimensions you pass to your NSBitmapImageRep initializer are in points (not pixels). An 150.0 point-wide image therefore uses 300 horizontal pixels in a @2x context.

You could use convertRectToBacking: or backingScaleFactor: to compensate for the @2x context. (I didn't try that), or you can use the following NSImage category, that creates a drawing context with explicit pixel dimensions:

@interface NSImage (SSWPNGAdditions)

- (BOOL)writePNGToURL:(NSURL*)URL outputSizeInPixels:(NSSize)outputSizePx error:(NSError*__autoreleasing*)error;

@end

@implementation NSImage (SSWPNGAdditions)

- (BOOL)writePNGToURL:(NSURL*)URL outputSizeInPixels:(NSSize)outputSizePx error:(NSError*__autoreleasing*)error
{
    BOOL result = YES;
    NSImage* scalingImage = [NSImage imageWithSize:[self size] flipped:NO drawingHandler:^BOOL(NSRect dstRect) {
        [self drawAtPoint:NSMakePoint(0.0, 0.0) fromRect:dstRect operation:NSCompositeSourceOver fraction:1.0];
        return YES;
    }];
    NSRect proposedRect = NSMakeRect(0.0, 0.0, outputSizePx.width, outputSizePx.height);
    CGColorSpaceRef colorSpace = CGColorSpaceCreateWithName(kCGColorSpaceGenericRGB);
    CGContextRef cgContext = CGBitmapContextCreate(NULL, proposedRect.size.width, proposedRect.size.height, 8, 4*proposedRect.size.width, colorSpace, kCGBitmapByteOrderDefault|kCGImageAlphaPremultipliedLast);
    CGColorSpaceRelease(colorSpace);
    NSGraphicsContext* context = [NSGraphicsContext graphicsContextWithGraphicsPort:cgContext flipped:NO];
    CGContextRelease(cgContext);
    CGImageRef cgImage = [scalingImage CGImageForProposedRect:&proposedRect context:context hints:nil];
    CGImageDestinationRef destination = CGImageDestinationCreateWithURL((__bridge CFURLRef)(URL), kUTTypePNG, 1, NULL);
    CGImageDestinationAddImage(destination, cgImage, nil);
    if(!CGImageDestinationFinalize(destination))
    {
        NSDictionary* details = @{NSLocalizedDescriptionKey:@"Error writing PNG image"};
        [details setValue:@"ran out of money" forKey:NSLocalizedDescriptionKey];
        *error = [NSError errorWithDomain:@"SSWPNGAdditionsErrorDomain" code:10 userInfo:details];
        result = NO;
    }
    CFRelease(destination);
    return result;
}

@end
Floccose answered 7/7, 2013 at 8:0 Comment(4)
I tried a bunch of different solutions (incl. the accepted answer here). This is the only solution I have tried that works. Thanks!Vincent
I'm implementing your solution. I get the warning "isFlipped is deprecated: first deprecated in OS X 10.6". Should I ignore the warning, or better remove the call?Cosset
Also, "Implicit conversion from enumeration type 'enum CGImageAlphaInfo' to different enumeration type 'CGBitmapInfo' (aka 'enum CGBitmapInfo')". This one seems to be a more serious warning. I checked the definition of those two enums, and they are completely different. However, CGBitmapInfo doesn't have any constant for 'premultiplied alpha'.Cosset
Thanks for pointing out the warnings. I fixed my original answer.Floccose
J
6

I found this code on web, and it works on retina. Paste here, hope can help someone.

NSImage *computerImage = [NSImage imageNamed:NSImageNameComputer];
NSInteger size = 256;
 
NSBitmapImageRep *rep = [[NSBitmapImageRep alloc]
                  initWithBitmapDataPlanes:NULL
                                pixelsWide:size
                                pixelsHigh:size
                             bitsPerSample:8
                           samplesPerPixel:4
                                  hasAlpha:YES
                                  isPlanar:NO
                            colorSpaceName:NSCalibratedRGBColorSpace
                               bytesPerRow:0
                              bitsPerPixel:0];
[rep setSize:NSMakeSize(size, size)];
 
[NSGraphicsContext saveGraphicsState];
[NSGraphicsContext setCurrentContext:[NSGraphicsContext     graphicsContextWithBitmapImageRep:rep]];
[computerImage drawInRect:NSMakeRect(0, 0, size, size)  fromRect:NSZeroRect operation:NSCompositeCopy fraction:1.0];
[NSGraphicsContext restoreGraphicsState];
 
NSData *data = [rep representationUsingType:NSPNGFileType properties:nil];
Jacynth answered 20/3, 2015 at 8:44 Comment(2)
gist.github.com/mattstevens/4400775 Resizing an NSImage on retina Macs for output as a 1x imageJacynth
This doesn't seem a direct answer to the questionOccidentalize
O
6

Here's a Swift 5 version based on Heinrich Giesen's answer:

static func saveImage(_ image: NSImage, atUrl url: URL) {
    guard
        let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil)
        else { return } // TODO: handle error
    let newRep = NSBitmapImageRep(cgImage: cgImage)
    newRep.size = image.size // if you want the same size
    guard
        let pngData = newRep.representation(using: .png, properties: [:])
        else { return } // TODO: handle error
    do {
        try pngData.write(to: url)
    }
    catch {
        print("error saving: \(error)")
    }
}
Outfit answered 6/5, 2019 at 0:6 Comment(1)
Unfortunately Heinrich's answer doesn't work. My Swift code looks a lot like this but it saves an image that is 2x as wide and tall.Casiecasilda
I
4

Just in case anyone stumbles upon this thread, here is a certainly flawed solution that does the job of saving an image at 1x size (image.size) regardless of device in Swift:

public func writeToFile(path: String, atomically: Bool = true) -> Bool{

    let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(self.size.width), pixelsHigh: Int(self.size.height), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: NSDeviceRGBColorSpace, bytesPerRow: 0, bitsPerPixel: 0)!
    bitmap.size = self.size

    NSGraphicsContext.saveGraphicsState()

    NSGraphicsContext.setCurrentContext(NSGraphicsContext(bitmapImageRep: bitmap))
    self.drawAtPoint(CGPoint.zero, fromRect: NSRect.zero, operation: NSCompositingOperation.CompositeSourceOver, fraction: 1.0)
    NSGraphicsContext.restoreGraphicsState()

    if let imagePGNData = bitmap.representationUsingType(NSBitmapImageFileType.NSPNGFileType, properties: [NSImageCompressionFactor: 1.0]) {
        return imagePGNData.writeToFile((path as NSString).stringByStandardizingPath, atomically: atomically)
    } else {
        return false
    }
}
Isiahisiahi answered 19/8, 2015 at 21:41 Comment(0)
A
0

My 2 cents for OS X including write that handles extensions + offscreen image drawing (method 2); one can verify with NSGraphicsContext.currentContextDrawingToScreen()

func createCGImage() -> CGImage? {
    
    //method 1
    let image = NSImage(size: NSSize(width: bounds.width, height: bounds.height), flipped: true, drawingHandler: { rect in
        self.drawRect(self.bounds)
        return true
    })
    var rect = CGRectMake(0, 0, bounds.size.width, bounds.size.height)
    return image.CGImageForProposedRect(&rect, context: bitmapContext(), hints: nil)

    
    //method 2
    if let pdfRep = NSPDFImageRep(data: dataWithPDFInsideRect(bounds)) {
        return pdfRep.CGImageForProposedRect(&rect, context: bitmapContext(), hints: nil)
    }
    return nil
}

func PDFImageData(filter: QuartzFilter?) -> NSData? {
    return dataWithPDFInsideRect(bounds)
}

func bitmapContext() -> NSGraphicsContext? {
    var context : NSGraphicsContext? = nil
    if let imageRep =  NSBitmapImageRep(bitmapDataPlanes: nil,
                                        pixelsWide: Int(bounds.size.width),
                                        pixelsHigh: Int(bounds.size.height), bitsPerSample: 8,
                                        samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
                                        colorSpaceName: NSCalibratedRGBColorSpace,
                                        bytesPerRow: Int(bounds.size.width) * 4,
                                        bitsPerPixel: 32) {
        imageRep.size = NSSize(width: bounds.size.width, height: bounds.size.height)
        context = NSGraphicsContext(bitmapImageRep: imageRep)
    }
    return context
}

func writeImageData(view: MyView, destination: NSURL) {
    if let dest = CGImageDestinationCreateWithURL(destination, imageUTType, 1, nil) {
        let properties  = imageProperties
        let image = view.createCGImage()!
        let queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)
        dispatch_async(queue) {
            CGImageDestinationAddImage(dest, image, properties)
            CGImageDestinationFinalize(dest)
        }
    }
}
Absurdity answered 13/9, 2016 at 14:4 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.