CGRotation¶
CGRotation is a Cocoa application that demonstrates how to use the Current Transformation Matrix (CTM) to apply rotation, scaling and transformation to an image for display. It also demonstrates simple usage of ImageIO to load and save images and how to get the pixel data from a CGImageRef into an arbitrary buffer.
Sources¶
CGImageUtils.py¶
import math
import Cocoa
import LaunchServices
import objc
import Quartz
class ImageInfo(object):
__slots__ = (
"fRotation",
"fScaleX",
"fScaleY",
"fTranslateX",
"fTranslateY",
"fImageRef",
"fProperties",
"fOrientation",
)
def __init__(self):
self.fRotation = 0.0 # The rotation about the center of the image (degrees)
self.fScaleX = 0.0 # The scaling of the image along it's X-axis
self.fScaleY = 0.0 # The scaling of the image along it's Y-axis
self.fTranslateX = 0.0 # Move the image along the X-axis
self.fTranslateY = 0.0 # Move the image along the Y-axis
self.fImageRef = None # The image itself
self.fProperties = None # Image properties
self.fOrientation = (
None # Affine transform that ensures the image displays correctly
)
# Create a new image from a file at the given url
# Returns None if unsuccessful.
def IICreateImage(url):
ii = None
# Try to create an image source to the image passed to us
imageSrc = Quartz.CGImageSourceCreateWithURL(url, None)
if imageSrc is not None:
# And if we can, try to obtain the first image available
image = Quartz.CGImageSourceCreateImageAtIndex(imageSrc, 0, None)
if image is not None:
# and if we could, create the ImageInfo struct with default values
ii = ImageInfo()
ii.fRotation = 0.0
ii.fScaleX = 1.0
ii.fScaleY = 1.0
ii.fTranslateX = 0.0
ii.fTranslateY = 0.0
# the ImageInfo struct owns this CGImageRef now, so no need for a retain.
ii.fImageRef = image
# the ImageInfo struct owns this CFDictionaryRef, so no need for a retain.
ii.fProperties = Quartz.CGImageSourceCopyPropertiesAtIndex(
imageSrc, 0, None
)
# Setup the orientation transformation matrix so that the image will
# display with the proper orientation
IIGetOrientationTransform(ii)
return ii
# Transforms the context based on the orientation of the image.
# This ensures the image always appears correctly when drawn.
def IIGetOrientationTransform(image):
w = Quartz.CGImageGetWidth(image.fImageRef)
h = Quartz.CGImageGetHeight(image.fImageRef)
if image.fProperties is not None:
# The Orientations listed here are mirroed from CGImageProperties.h,
# listed under the kCGImagePropertyOrientation key.
orientation = IIGetImageOrientation(image)
if orientation == 1:
# 1 = 0th row is at the top, and 0th column is on the left.
# Orientation Normal
image.fOrientation = Quartz.CGAffineTransformMake(
1.0, 0.0, 0.0, 1.0, 0.0, 0.0
)
elif orientation == 2:
# 2 = 0th row is at the top, and 0th column is on the right.
# Flip Horizontal
image.fOrientation = Quartz.CGAffineTransformMake(
-1.0, 0.0, 0.0, 1.0, w, 0.0
)
elif orientation == 3:
# 3 = 0th row is at the bottom, and 0th column is on the right.
# Rotate 180 degrees
image.fOrientation = Quartz.CGAffineTransformMake(
-1.0, 0.0, 0.0, -1.0, w, h
)
elif orientation == 4:
# 4 = 0th row is at the bottom, and 0th column is on the left.
# Flip Vertical
image.fOrientation = Quartz.CGAffineTransformMake(1.0, 0.0, 0, -1.0, 0.0, h)
elif orientation == 5:
# 5 = 0th row is on the left, and 0th column is the top.
# Rotate -90 degrees and Flip Vertical
image.fOrientation = Quartz.CGAffineTransformMake(
0.0, -1.0, -1.0, 0.0, h, w
)
elif orientation == 6:
# 6 = 0th row is on the right, and 0th column is the top.
# Rotate 90 degrees
image.fOrientation = Quartz.CGAffineTransformMake(
0.0, -1.0, 1.0, 0.0, 0.0, w
)
elif orientation == 7:
# 7 = 0th row is on the right, and 0th column is the bottom.
# Rotate 90 degrees and Flip Vertical
image.fOrientation = Quartz.CGAffineTransformMake(
0.0, 1.0, 1.0, 0.0, 0.0, 0.0
)
elif orientation == 8:
# 8 = 0th row is on the left, and 0th column is the bottom.
# Rotate -90 degrees
image.fOrientation = Quartz.CGAffineTransformMake(
0.0, 1.0, -1.0, 0.0, h, 0.0
)
# Gets the orientation of the image from the properties dictionary if available
# If the kCGImagePropertyOrientation is not available or invalid,
# then 1, the default orientation, is returned.
def IIGetImageOrientation(image):
result = 1
if image.fProperties is not None:
orientation = image.fProperties.get(Quartz.kCGImagePropertyOrientation)
if orientation is not None:
result = orientation
return result
# Save the given image to a file at the given url.
# Returns true if successful, false otherwise.
def IISaveImage(image, url, width, height):
result = False
# If there is no image, no destination, or the width/height is 0, then fail early.
assert (
(image is not None) and (url is not None) and (width != 0.0) and (height != 0.0)
)
# Try to create a jpeg image destination at the url given to us
imageDest = Quartz.CGImageDestinationCreateWithURL(
url, LaunchServices.kUTTypeJPEG, 1, None
)
if imageDest is not None:
# And if we can, then we can start building our final image.
# We begin by creating a CGBitmapContext to host our desintation image.
# Allocate enough space to hold our pixels
imageData = objc.allocateBuffer(int(4 * width * height))
# Create the bitmap context
bitmapContext = Quartz.CGBitmapContextCreate(
imageData, # image data we just allocated...
width, # width
height, # height
8, # 8 bits per component
4 * width, # bytes per pixel times number of pixels wide
Quartz.CGImageGetColorSpace(
image.fImageRef
), # use the same colorspace as the original image
Quartz.kCGImageAlphaPremultipliedFirst,
) # use premultiplied alpha
# Check that all that went well
if bitmapContext is not None:
# Now, we draw the image to the bitmap context
IIDrawImageTransformed(
image, bitmapContext, Quartz.CGRectMake(0.0, 0.0, width, height)
)
# We have now gotten our image data to the bitmap context, and correspondingly
# into imageData. If we wanted to, we could look at any of the pixels of the image
# and manipulate them in any way that we desire, but for this case, we're just
# going to ask ImageIO to write this out to disk.
# Obtain a CGImageRef from the bitmap context for ImageIO
imageIOImage = Quartz.CGBitmapContextCreateImage(bitmapContext)
# Check if we have additional properties from the original image
if image.fProperties is not None:
# If we do, then we want to inspect the orientation property.
# If it exists and is not the default orientation, then we
# want to replace that orientation in the destination file
orientation = IIGetImageOrientation(image)
if orientation != 1:
# If the orientation in the original image was not the default,
# then we need to replace that key in a duplicate of that dictionary
# and then pass that dictionary to ImageIO when adding the image.
prop = Cocoa.CFDictionaryCreateMutableCopy(
None, 0, image.fProperties
)
orientation = 1
prop[Quartz.kCGImagePropertyOrientation] = orientation
# And add the image with the new properties
Quartz.CGImageDestinationAddImage(imageDest, imageIOImage, prop)
else:
# Otherwise, the image was already in the default orientation and we can
# just save it with the original properties.
Quartz.CGImageDestinationAddImage(
imageDest, imageIOImage, image.fProperties
)
else:
# If we don't, then just add the image without properties
Quartz.CGImageDestinationAddImage(imageDest, imageIOImage, None)
del bitmapContext
# Finalize the image destination
result = Quartz.CGImageDestinationFinalize(imageDest)
del imageDest
return result
# Applies the transformations specified in the ImageInfo struct without drawing the actual image
def IIApplyTransformation(image, context, bounds):
if image is not None:
# Whenever you do multiple CTM changes, you have to be very careful with
# order. Changing the order of your CTM changes changes the outcome of
# the drawing operation. For example, if you scale a context by 2.0 along
# the x-axis, and then translate the context by 10.0 along the x-axis,
# then you will see your drawing will be in a different position than if
# you had done the operations in the opposite order.
#
# Our intent with this operation is that we want to change the location
# from which we start drawing (translation), then rotate our axies so
# that our image appears at an angle (rotation), and finally
# scale our axies so that our image has a different size (scale).
# Changing the order of operations will markedly change the results.
IITranslateContext(image, context)
IIRotateContext(image, context, bounds)
IIScaleContext(image, context, bounds)
# Draw the image to the given context centered inside the given bounds
def IIDrawImage(image, context, bounds):
imageRect = Cocoa.NSRect()
if image is not None and context is not None:
# Setup the image rect so that the image fills it's natural boudaries
# in the base coordinate system.
imageRect.origin.x = 0.0
imageRect.origin.y = 0.0
imageRect.size.width = Quartz.CGImageGetWidth(image.fImageRef)
imageRect.size.height = Quartz.CGImageGetHeight(image.fImageRef)
# Obtain the orientation matrix for this image
ctm = image.fOrientation
# Before we can apply the orientation matrix, we need to translate the
# coordinate system so the center of the rectangle matces the center of
# the image.
if image.fProperties is None or IIGetImageOrientation(image) < 5:
# For orientations 1-4, the images are unrotated, so the width and
# height of the base image can be used as the width and height of
# the coordinate translation calculation.
Quartz.CGContextTranslateCTM(
context,
math.floor((bounds.size.width - imageRect.size.width) / 2.0),
math.floor((bounds.size.height - imageRect.size.height) / 2.0),
)
else:
# For orientations 5-8, the images are rotated 90 or -90 degrees,
# so we need to use the image width in place of the height and
# vice versa.
Quartz.CGContextTranslateCTM(
context,
math.floor((bounds.size.width - imageRect.size.height) / 2.0),
math.floor((bounds.size.height - imageRect.size.width) / 2.0),
)
# Finally, orient the context so that the image draws naturally.
Quartz.CGContextConcatCTM(context, ctm)
# And draw the image.
Quartz.CGContextDrawImage(context, imageRect, image.fImageRef)
# Rotates the context around the center point of the given bounds
def IIRotateContext(image, context, bounds):
# First we translate the context such that the 0,0 location is at the center of the bounds
Quartz.CGContextTranslateCTM(
context, bounds.size.width / 2.0, bounds.size.height / 2.0
)
# Then we rotate the context, converting our angle from degrees to radians
Quartz.CGContextRotateCTM(context, image.fRotation * math.pi / 180.0)
# Finally we have to restore the center position
Quartz.CGContextTranslateCTM(
context, -bounds.size.width / 2.0, -bounds.size.height / 2.0
)
# Scale the context around the center point of the given bounds
def IIScaleContext(image, context, bounds):
# First we translate the context such that the 0,0 location is at the center of the bounds
Quartz.CGContextTranslateCTM(
context, bounds.size.width / 2.0, bounds.size.height / 2.0
)
# Next we scale the context to the size that we want
Quartz.CGContextScaleCTM(context, image.fScaleX, image.fScaleY)
# Finally we have to restore the center position
Quartz.CGContextTranslateCTM(
context, -bounds.size.width / 2.0, -bounds.size.height / 2.0
)
# Translate the context
def IITranslateContext(image, context):
# Translation is easy, just translate.
Quartz.CGContextTranslateCTM(context, image.fTranslateX, image.fTranslateY)
# Draw the image to the given context centered inside the given bounds with
# the transformation info. The CTM of the context is unchanged after this call
def IIDrawImageTransformed(image, context, bounds):
# We save the current graphics state so as to not disrupt it for the caller.
Quartz.CGContextSaveGState(context)
# Apply the transformation
IIApplyTransformation(image, context, bounds)
# Draw the image centered in the context
IIDrawImage(image, context, bounds)
# Restore our original graphics state.
Quartz.CGContextRestoreGState(context)
# Release the ImageInfo struct and other associated data
# you should not refer to the reference after this call
# This function is None safe.
def IIRelease(image):
pass
CGImageView.py¶
import CGImageUtils
import Cocoa
import objc
import Quartz
class CGImageView(Cocoa.NSView):
_image = objc.ivar()
def setImage_(self, img):
if img is not None and self._image is not img:
self._image = img
# Mark this view as needing to be redisplayed.
self.setNeedsDisplay_(True)
def image(self):
return self._image
def drawRect_(self, rect):
# Obtain the current context
ctx = Cocoa.NSGraphicsContext.currentContext().graphicsPort()
# Draw the image in the context
CGImageUtils.IIDrawImageTransformed(
self._image,
ctx,
Quartz.CGRectMake(
rect.origin.x, rect.origin.y, rect.size.width, rect.size.height
),
)
# Draw the view border, just a simple stroked rectangle
Quartz.CGContextAddRect(
ctx,
Quartz.CGRectMake(
rect.origin.x, rect.origin.y, rect.size.width, rect.size.height
),
)
Quartz.CGContextSetRGBStrokeColor(ctx, 1.0, 0.0, 0.0, 1.0)
Quartz.CGContextStrokePath(ctx)
Controller.py¶
import math
import CGImageUtils
import Cocoa
import LaunchServices
import objc
import Quartz
class Controller(Cocoa.NSObject):
imageView = objc.IBOutlet()
scaleYView = objc.IBOutlet()
textScaleYView = objc.IBOutlet()
_rotation = objc.ivar.float()
_scaleX = objc.ivar.float()
_scaleY = objc.ivar.float()
_translateX = objc.ivar.float()
_translateY = objc.ivar.float()
_preserveAspectRatio = objc.ivar.bool()
openImageIOSupportedTypes = objc.ivar()
def awakeFromNib(self):
self.openImageIOSupportedTypes = None
# Ask CFBundle for the location of our demo image
url = Cocoa.CFBundleCopyResourceURL(
Cocoa.CFBundleGetMainBundle(), "demo", "png", None
)
if url is not None:
# And if available, load it
self.imageView.setImage_(CGImageUtils.IICreateImage(url))
self.imageView.window().center()
self.setRotation_(0.0)
self.setScaleX_(1.0)
self.setScaleY_(1.0)
self.setTranslateX_(0.0)
self.setTranslateY_(0.0)
self.setPreserveAspectRatio_(False)
@objc.IBAction
def changeScaleX_(self, sender):
self.setScaleX_(self._scaleX + sender.floatValue())
sender.setFloatValue_(0.0)
@objc.IBAction
def changeScaleY_(self, sender):
self.setScaleY_(self._scaleY + sender.floatValue())
sender.setFloatValue_(0.0)
@objc.IBAction
def changeTranslateX_(self, sender):
self.setTranslateX_(self._translateX + sender.floatValue())
sender.setFloatValue_(0.0)
@objc.IBAction
def changeTranslateY_(self, sender):
self.setTranslateY_(self._translateY + sender.floatValue())
sender.setFloatValue_(0.0)
@objc.IBAction
def reset_(self, sender):
self.setRotation_(0.0)
self.setScaleX_(1.0)
self.setScaleY_(1.0)
self.setTranslateX_(0.0)
self.setTranslateY_(0.0)
self.imageView.setNeedsDisplay_(True)
def extensionsForUTI_(self, uti):
"""
Returns an array with the extensions that match the given
Uniform Type Identifier (UTI).
"""
# If anything goes wrong, we'll return None, otherwise this will be the array
# of extensions for this image type.
extensions = None
# Only get extensions for UTIs that are images (i.e. conforms to
# public.image aka kUTTypeImage) This excludes PDF support that ImageIO
# advertises, but won't actually use.
if LaunchServices.UTTypeConformsTo(uti, LaunchServices.kUTTypeImage):
# Copy the decleration for the UTI (if it exists)
decleration = LaunchServices.UTTypeCopyDeclaration(uti)
if decleration is not None:
# Grab the tags for this UTI, which includes extensions, OSTypes and MIME types.
tags = Cocoa.CFDictionaryGetValue(
decleration, LaunchServices.kUTTypeTagSpecificationKey
)
if tags is not None:
# We are interested specifically in the extensions that this UTI uses
filenameExtensions = tags.get(
LaunchServices.kUTTagClassFilenameExtension
)
if filenameExtensions is not None:
# It is valid for a UTI to export either an Array
# (of Strings) representing multiple tags, or a String
# representing a single tag.
type_id = Cocoa.CFGetTypeID(filenameExtensions)
if type_id == Cocoa.CFStringGetTypeID():
# If a string was exported, then wrap it up in an array.
extensions = Cocoa.NSArray.arrayWithObject_(
filenameExtensions
)
elif type_id == Cocoa.CFArrayGetTypeID():
# If an array was exported, then just return that array.
extensions = filenameExtensions.copy()
return extensions
# On Tiger NSOpenPanel only understands extensions, not UTIs, so we have to
# obtain a list of extentions from the UTIs that Image IO tells us it can
# handle.
def createOpenTypesArray(self):
if self.openImageIOSupportedTypes is None:
imageIOUTIs = Quartz.CGImageSourceCopyTypeIdentifiers()
count = len(imageIOUTIs)
self.openImageIOSupportedTypes = Cocoa.NSMutableArray.alloc().initWithCapacity_(
count
)
for i in range(count):
self.openImageIOSupportedTypes.addObjectsFromArray_(
self.extensionsForUTI_(imageIOUTIs[i])
)
@objc.IBAction
def openDocument_(self, sender):
panel = Cocoa.NSOpenPanel.openPanel()
panel.setAllowsMultipleSelection_(False)
panel.setResolvesAliases_(True)
panel.setTreatsFilePackagesAsDirectories_(True)
self.createOpenTypesArray()
panel.beginSheetForDirectory_file_types_modalForWindow_modalDelegate_didEndSelector_contextInfo_( # noqa: B950
None,
None,
self.openImageIOSupportedTypes,
self.imageView.window(),
self,
"openImageDidEnd:returnCode:contextInfo:",
None,
)
@objc.signature(b"v@:@i^v")
def openImageDidEnd_returnCode_contextInfo_(self, panel, returnCode, contextInfo_):
if returnCode == Cocoa.NSOKButton:
if len(panel.filenames()) > 0:
image = CGImageUtils.IICreateImage(
Cocoa.NSURL.fileURLWithPath_(panel.filenames()[0])
)
if image is not None:
# Ownership is transferred to the CGImageView.
self.imageView.setImage_(image)
@objc.IBAction
def saveDocumentAs_(self, sender):
panel = Cocoa.NSSavePanel.savePanel()
panel.setCanSelectHiddenExtension_(True)
panel.setRequiredFileType_("jpeg")
panel.setAllowsOtherFileTypes_(False)
panel.setTreatsFilePackagesAsDirectories_(True)
panel.beginSheetForDirectory_file_modalForWindow_modalDelegate_didEndSelector_contextInfo_( # noqa: B950
None,
"untitled image",
self.imageView.window(),
self,
"saveImageDidEnd:returnCode:contextInfo:",
None,
)
@objc.signature(b"v@:@i^v")
def saveImageDidEnd_returnCode_contextInfo_(self, panel, returnCode, contextInfo):
if returnCode == Cocoa.NSOKButton:
frame = self.imageView.frame()
CGImageUtils.IISaveImage(
self.imageView.image(),
panel.URL(),
math.ceil(frame.size.width),
math.ceil(frame.size.height),
)
def setRotation_(self, r):
r = r % 360.0
if r < 0:
r += 360.0
self._rotation = r
self.imageView.image().fRotation = 360.0 - r # XXX
self.imageView.setNeedsDisplay_(True)
def setScaleX_(self, x):
self._scaleX = x
self.imageView.image().fScaleX = self._scaleX
if self._preserveAspectRatio:
self.imageView.image().fScaleY = self._scaleX
self.imageView.setNeedsDisplay_(True)
def setScaleY_(self, y):
self._scaleY = y
if not self._preserveAspectRatio:
self.imageView.image().fScaleY = self._scaleY
self.imageView.setNeedsDisplay_(True)
def setPreserveAspectRatio_(self, preserve):
self._preserveAspectRatio = preserve
self.imageView.image().fScaleX = self._scaleX
if self._preserveAspectRatio:
self.imageView.image().fScaleY = self._scaleX
else:
self.imageView.image().fScaleY = self._scaleY
self.scaleYView.setEnabled_(not self._preserveAspectRatio)
self.textScaleYView.setEnabled_(not self._preserveAspectRatio)
self.imageView.setNeedsDisplay_(True)
def setTranslateX_(self, x):
self._translateX = x
self.imageView.image().fTranslateX = self._translateX
self.imageView.setNeedsDisplay_(True)
def setTranslateY_(self, y):
self._translateY = y
self.imageView.image().fTranslateY = self._translateY
self.imageView.setNeedsDisplay_(True)
def rotation(self):
return self._rotation
def scaleX(self):
return self._scaleX
def scaleY(self):
return self._scaleY
def preserveAspectRatio(self):
return self._preserveAspectRatio
def translateX(self):
return self._translateX
def translateY(self):
return self._translateY
main.py¶
import CGImageUtils # noqa: F401
import CGImageView # noqa: F401
import Controller # noqa: F401
from PyObjCTools import AppHelper
AppHelper.runEventLoop()
setup.py¶
"""
Script for building the example.
Usage:
python3 setup.py py2app
"""
from setuptools import setup
setup(
name="CGRotation",
app=["main.py"],
data_files=["English.lproj", "demo.png"],
setup_requires=["py2app", "pyobjc-framework-Cocoa", "pyobjc-framework-Quartz"],
)