If you need the same logo on thirty photos, the boring bugs show up immediately: the mark looks huge on a 12-megapixel still and timid on a 48-megapixel one, the PNG’s transparent background turns into a grey box, or the edges of the logo go muddy because the alpha channel was applied twice.
This post walks through a small UIKit pipeline that loads a transparent PNG, draws each photo, then draws the logo at a size tied to the photo’s shorter side, inset by a percentage of the frame. I’m writing it from the seat of someone who builds BatchMark, an on-device batch photo app; the batch watermark path is where this idea lives in the product. The code below is a teaching extract, not a dump of the app.
What “the same logo” should mean
People say “put the logo in the corner” and mean three separate decisions:
- How large is it, visually? Not “200 pixels,” because 200 pixels is a different object on a 4032-pixel-wide photo than on a 8064-pixel-wide one.
- How far is it from the edges? A 40-point inset looks generous on a screenshot and lost on a full-resolution export.
- How does it sit on top of the photo? The PNG already has transparency. You want that transparency, plus an optional fade, without flattening the logo first.
Get those three right once, store them as a spec, and run the spec across the batch. The photos can differ in size and orientation; the mark should still feel like the same stamp.
Load the PNG and keep its alpha
Load the logo as a UIImage from your bundle or from a file URL. UIImage preserves the PNG alpha channel. The thing not to do is round-trip the logo through JPEG, which has no alpha and will give you an opaque rectangle where the transparency used to be.
import UIKit
func loadLogoPNG(named name: String) -> UIImage? {
// Asset catalogue or bundled PNG. Do not encode this as JPEG.
UIImage(named: name)
}
func loadLogoPNG(from url: URL) -> UIImage? {
UIImage(contentsOfFile: url.path)
}
If you support user-picked logos from the photo library, make sure you ask for the original file when you can. A preview JPEG from a share sheet is how transparent marks accidentally become stickers with a white mat.
Apple’s UIImage drawing methods composite that alpha for you, which is what we want in the next step.
Draw in pixel space
UIImage.size is in points. Export cares about pixels. Multiply by scale, and build a UIGraphicsImageRenderer whose point size equals the pixel canvas you want, with scale = 1. That way a 4032×3024 photo produces a 4032×3024 result, not a size that surprises you on a 3× device.
func pixelSize(of image: UIImage) -> CGSize {
CGSize(
width: image.size.width * image.scale,
height: image.size.height * image.scale
)
}
func makeRenderer(pixelSize: CGSize, opaque: Bool) -> UIGraphicsImageRenderer {
let format = UIGraphicsImageRendererFormat.default()
format.scale = 1
format.opaque = opaque
return UIGraphicsImageRenderer(size: pixelSize, format: format)
}
For finished photos the canvas is opaque, so opaque: true is correct and slightly cheaper. You would only set opaque to false if the output needed transparency, which a typical watermarked JPEG or HEIF export does not.
Drawing with UIImage.draw(in:) also respects image orientation, so you are laying the logo onto the picture the user actually sees, not onto a sideways pixel buffer.
Size the logo from the shorter side
This is not a long essay about resolution-independent layout — just the one rule that keeps a batch looking even. Measure the photo in pixels, take the shorter side, and make the logo’s width a fraction of that length. A 12-megapixel shot and a 48-megapixel shot then get the same visual weight, because both marks occupy the same proportion of the frame.
struct LogoStampSpec {
/// Logo width as a fraction of the photo's shorter side (for example 0.18).
var relativeWidth: CGFloat
/// Inset from the edges as a fraction of the matching photo dimension.
var edgePadding: CGFloat
var opacity: CGFloat
var corner: Corner
enum Corner {
case topLeft, topRight, bottomLeft, bottomRight
}
static let `default` = LogoStampSpec(
relativeWidth: 0.18,
edgePadding: 0.04,
opacity: 0.85,
corner: .bottomRight
)
}
func logoRect(
in photoPixels: CGSize,
logo: UIImage,
spec: LogoStampSpec
) -> CGRect {
let shorter = min(photoPixels.width, photoPixels.height)
let width = shorter * spec.relativeWidth
let aspect = logo.size.width / max(logo.size.height, 0.0001)
let height = width / aspect
let padX = photoPixels.width * spec.edgePadding
let padY = photoPixels.height * spec.edgePadding
let origin: CGPoint
switch spec.corner {
case .topLeft:
origin = CGPoint(x: padX, y: padY)
case .topRight:
origin = CGPoint(x: photoPixels.width - padX - width, y: padY)
case .bottomLeft:
origin = CGPoint(x: padX, y: photoPixels.height - padY - height)
case .bottomRight:
origin = CGPoint(
x: photoPixels.width - padX - width,
y: photoPixels.height - padY - height
)
}
return CGRect(origin: origin, size: CGSize(width: width, height: height))
}
Padding is a percentage of each dimension rather than a fixed pixel inset, for the same reason as the logo width: four percent of a wide landscape photo is a different number of pixels than four percent of a square, but it feels like the same margin. Using the photo’s width for the horizontal inset and the height for the vertical inset keeps the mark from kissing one edge when the aspect ratio is extreme.
Draw the photo, then the logo
Order matters. Paint the photograph first so it becomes the destination. Then composite the logo on top with .normal blending and a single opacity value.
func stampLogo(on photo: UIImage, logo: UIImage, spec: LogoStampSpec) -> UIImage {
let canvas = pixelSize(of: photo)
let renderer = makeRenderer(pixelSize: canvas, opaque: true)
let markRect = logoRect(in: canvas, logo: logo, spec: spec)
return renderer.image { _ in
photo.draw(in: CGRect(origin: .zero, size: canvas))
logo.draw(in: markRect, blendMode: .normal, alpha: spec.opacity)
}
}
.normal plus the PNG’s own alpha is the combination you want for a regular logo. You are not replacing destination pixels (that would be .copy, which punches an opaque rectangle). You are not adding light (that would be something like .plusLighter, which makes pale logos glow and dark ones disappear).
Don’t double-premultiply the alpha
PNG bitmaps on iOS are typically stored premultiplied: the colour channels have already been multiplied by the alpha. UIImage.draw(in:blendMode:alpha:) knows that, and it knows how to apply your extra alpha fade on top without flattening the image first.
The muddy-edge bug usually comes from applying transparency twice:
- Drawing the logo into an intermediate context with
alphaalready applied, then drawing that intermediate image withalphaagain. - Calling
cgContext.setAlphaand also passingalpha:intodraw. - Expanding the logo yourself into straight (un-premultiplied) RGBA, then handing it to an API that expects premultiplied pixels. Dark fringes appear around antialiased type and around any soft shadow in the PNG.
The safe teaching version is: load once, draw once, pass the fade as the alpha argument a single time, and don’t pre-flatten the logo “to be helpful.” If you need a colour overlay on a monochrome mark, build that overlay in a separate step with a dedicated tint helper; don’t multiply the whole image by a colour and then fade it again.
Apply the same spec to many photos
Once stampLogo(on:logo:spec:) is a pure function of one photo and one spec, the batch is a loop. Keep the spec identical. That is what makes the set look like one shop rather than thirty separate design decisions.
func stampLogo(
on photos: [UIImage],
logo: UIImage,
spec: LogoStampSpec
) -> [UIImage] {
photos.map { stampLogo(on: $0, logo: logo, spec: spec) }
}
For large batches, prefer sequential mapping over fan-out parallelism. Each rendered photo is a full-resolution bitmap; creating many of them at once is how you run out of memory. If you export as you go — stamp, write JPEG or HEIF, drop the UIImage — the peak memory stays close to “one photo plus one logo” instead of “the whole album.”
A convenient call site looks like this:
guard let logo = loadLogoPNG(named: "studio-mark") else {
return
}
let spec = LogoStampSpec(
relativeWidth: 0.16,
edgePadding: 0.045,
opacity: 0.8,
corner: .bottomRight
)
let stamped = stampLogo(on: pickedPhotos, logo: logo, spec: spec)
Same logo file, same relative size, same padding, same fade, every time.
A few edges worth handling
-
Very wide or very tall photos. Because width is tied to the shorter side, a panorama does not get a logo the size of its long edge. That is usually what you want. If the mark still feels lost on an extreme crop, raise
relativeWidthslightly for that export preset rather than switching to an absolute pixel size. -
Tiny source logos. A 64-pixel icon scaled up onto a 48-megapixel photo will look soft. Prefer a vector export rendered to a generously sized PNG, or a PDF/SVG pipeline if you already have one. The placer above assumes the logo has enough pixels to survive being scaled to
relativeWidth. -
Output format. The renderer produces a
UIImage. Encode the photo as JPEG or HEIF as you normally would. Do not encode the logo that way before drawing.
None of this replaces design taste — a mark can still be too large, too opaque, or sitting on the product — but it does mean the implementation will not quietly change the design from one photo to the next.
I’m Maxwell. I build BatchMark as a longer-running experiment in making this class of finishing step usable on a phone, without uploading the files. The snippet above is the part I wish I had written down the first time a logo looked perfect on the first photo and wrong on the rest.
Top comments (0)