把一张 4000×3000 像素的图片放进 300×300 点的裁剪视图,选择中央区域,导出后却发现人物向上偏移。先别改手势代码:等比适应显示会在上下留下各 37.5 点空白。如果直接按整个视图换算,选区起点和高度都会出错。在云端 Mac 上先用纯 Swift 验证坐标,再接界面,可以把数学错误与渲染错误分开。
固定三套坐标的契约
明确三个对象:原图像素矩形、图片在视图中的实际显示矩形、用户拖出的选区。后两个对象必须位于同一坐标空间,不能一个相对父视图,另一个相对图片容器。
本文只处理等比适应显示,不处理填充裁切、旋转或透视。约定原点在左上角,横轴向右、纵轴向下;输入图片已经按显示方向规范化,宽高来自实际像素,而不是图片对象的点尺寸。
屏幕缩放倍率不是图片缩放比例。用屏幕倍率乘选区,只在偶然匹配的尺寸下正确,不能作为通用裁剪公式。
若界面使用嵌套视图,先通过坐标转换接口把选区转换到图片容器,再调用下面的函数。缩放手势存在时,还要先消除平移与缩放变换,不能把变换后的矩形直接套进静态公式。
实现显示矩形与像素换算
将以下内容保存为 CropCheck.swift。完整 Xcode 工具链可用时,在终端执行 xcrun swift CropCheck.swift。代码仅依赖系统 Foundation,不访问网络,也不需要模拟器。
import Foundation
func cropPixels(
source: CGSize,
viewport: CGSize,
selection: CGRect
) -> CGRect? {
let values = [
source.width, source.height,
viewport.width, viewport.height,
selection.origin.x, selection.origin.y,
selection.width, selection.height
]
guard values.allSatisfy({ $0.isFinite }),
source.width > 0, source.height > 0,
viewport.width > 0, viewport.height > 0,
source.width.rounded(.down) == source.width,
source.height.rounded(.down) == source.height,
selection.width > 0, selection.height > 0
else {
return nil
}
let scale = min(
viewport.width / source.width,
viewport.height / source.height
)
guard scale.isFinite, scale > 0 else {
return nil
}
let display = CGRect(
x: (viewport.width - source.width * scale) / 2,
y: (viewport.height - source.height * scale) / 2,
width: source.width * scale,
height: source.height * scale
)
let clipped = selection.intersection(display)
guard !clipped.isNull, !clipped.isEmpty else {
return nil
}
let left = max(
0, floor((clipped.minX - display.minX) / scale)
)
let top = max(
0, floor((clipped.minY - display.minY) / scale)
)
let right = min(
source.width,
ceil((clipped.maxX - display.minX) / scale)
)
let bottom = min(
source.height,
ceil((clipped.maxY - display.minY) / scale)
)
guard right > left, bottom > top else {
return nil
}
return CGRect(
x: left, y: top,
width: right - left, height: bottom - top
)
}
顺序不能交换:先求选区与显示区域的交集,再减去显示偏移,最后除以缩放比例。若先取整视图坐标,误差会被原图与视图的尺寸比例放大。
像素边界采用向外取整,目的是覆盖选区触及的像素。它不保证数学上的选区尺寸完全不变;如果产品要求固定输出尺寸,应把缩放导出作为后续独立步骤。
用可核算的样例阻断错位
继续把下面的断言追加到同一文件。成功执行时没有输出;任意断言失败,进程会以失败状态结束。不要使用会移除断言的优化方式运行这份验收脚本。
let source = CGSize(width: 4000, height: 3000)
let viewport = CGSize(width: 300, height: 300)
func mapped(_ rectangle: CGRect) -> CGRect? {
cropPixels(
source: source,
viewport: viewport,
selection: rectangle
)
}
assert(
mapped(CGRect(x: 75, y: 75, width: 150, height: 150))
== CGRect(x: 1000, y: 500, width: 2000, height: 2000)
)
assert(
mapped(CGRect(x: 0, y: 0, width: 300, height: 300))
== CGRect(x: 0, y: 0, width: 4000, height: 3000)
)
assert(
mapped(CGRect(x: 10, y: 0, width: 20, height: 20))
== nil
)
assert(
mapped(CGRect(x: 0, y: 0, width: 0, height: 20))
== nil
)
assert(
mapped(CGRect(x: 225, y: 225, width: 100, height: 100))
== CGRect(x: 3000, y: 2500, width: 1000, height: 500)
)
中央样例可以手算:显示比例为 0.075,选区起点扣除顶部留白后是 37.5 点,对应原图纵坐标 500 像素。最后一个样例同时越过右边和底边,用来证明函数确实裁掉越界部分,而非只限制起点。
再补充竖图、非正方形容器和小数选区。对任意有效结果,都检查宽高大于零、边界不超出原图、四条边均为整数。负宽高输入在这里直接拒绝;拖拽方向应由界面层提前规范化。
分开检查计算结果与实际导出
数学测试通过后,准备一张四角颜色不同、中心带十字的固定像素夹具。先用无损格式导出,避免有损编码让颜色断言出现噪声。
检查三项结果:导出宽高等于函数返回值;裁右下角时没有混入左上角标记;整图选择没有多出透明边。不要只比较文件字节,因为编码器附带的元数据变化不一定代表像素变化。
裁剪器若直接使用规范化后的像素图,传入本文的像素矩形;若通过图形上下文重新绘制,则另行确认原点方向和输出缩放参数。最常见的二次错误,是坐标函数已经返回像素,导出代码又乘了一次显示倍率。
图片异步加载时也要验收:用户选区之后若换入另一尺寸的原图,必须重新计算或使旧选区失效。不要把旧图的比例与新图的像素尺寸混用。
把失败记录变成可复现输入
每次失败保留原图像素宽高、容器点尺寸、选区矩形、实际显示模式和计算结果。记录数值即可,不必上传用户照片;复现时用相同尺寸的测试夹具替代。
合并前至少覆盖横图、竖图、全图选择、纯留白选择、边缘越界和小数边界六类输入。布局变化后必须重新读取容器尺寸,不能沿用旋转前的快照。
最终验收分两层:纯函数负责证明坐标关系,导出测试负责证明像素方向与尺寸。两层都通过,再用远程桌面检查拖拽体验。这样界面调整、原图替换和导出实现变更,都有各自明确的失败位置。
常见问题
裁剪坐标要不要乘屏幕缩放倍率?
不能直接乘。先根据原图像素尺寸和实际显示矩形计算比例;屏幕缩放倍率描述显示设备,不代表原图与视图之间的比例。
为什么像素边界采用向外取整?
向下取整最小边界、向上取整最大边界,可以覆盖连续选区涉及的像素。它最多向每侧扩展不足一个像素,取整后仍须限制在原图范围内。