扫描线种子填充算法基本步骤:
- 初始化一个空栈用于存放种子点,将种子点(x,y)入栈
- 判断栈是否为空,如果栈为空则算法结束,否则取出栈顶元素作为当前扫描线的种子点(x,y),y是当前的扫描线
- 从种子点(x,y)出发,沿当前扫描线向左向右两个方向填充,直到边界。分别标记区段的左右端点为xLeft,xRight
- 分别检查与当前扫描线相邻的y-1和y+1两条扫描线在区间[xLeft,xRight]中的像素,从xLeft开始xRight方向搜索,若存在非边界且未填充的像素点,则找出这些相邻像素点中最右边的一个,并将其作为种子点入栈,然后返回第2步(注:一条扫描线上可能存在多个种子点)
涂鸦效果
iOS中如何实现扫描线种子填充算法
-
扫描的是什么东东?
扫描的是图片上所有的像素点的集合,而常用的png,jpg是压缩过的位图,所以首先要把png,jpg图片进行解压缩
-
在iOS中如何把UIImage转成像素点的集合?
主要利用CGContext的下面三个API
//初始化 CGContext public init?(data: UnsafeMutableRawPointer?, width: Int, height: Int, bitsPerComponent: Int, bytesPerRow: Int, space: CGColorSpace, bitmapInfo: UInt32) //将位图也就是像素点的集合绘制到上下文中 public func draw(_ image: CGImage, in rect: CGRect) //得到上下文中的位图 public func makeImage() -> CGImage?
主要解释一下第一个方法的各个参数的含义
data:存放像素点的指针
width,height:位图的宽高
bitsPerComponent:颜色空间中每个通道占用的bit;(注,此单位是bit)
bytesPerRow:位图的每一行使用的字节数(注,此单位是byte,1byte=8bit)大小等于width*height*每个像素占用的大小,在iOS里颜色空间是RGB时,每个像素占用的大小是32
space:像素点的颜色空间
bitmapInfo:位图的布局信息,主要包含了alpha 的信息;颜色分量是否为浮点数;像素格式的字节顺序let image = UIImage(named: "test") if let imageRef = image?.cgImage { let width = imageRef.width let height = imageRef.height var pixels = Array
(repeating: 0, count: width * height) let colorSpace = CGColorSpaceCreateDeviceRGB() //像素点的颜色空间 let bitsPerComponent = 8 //颜色空间每个通道占用的bit let bytesPerRow = width * 4 //位图的每一行使用的字节数 let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue if let context = CGContext(data: &(pixels), width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: bytesPerRow, space: colorSpace, bitmapInfo: bitmapInfo) { context.draw(imageRef, in: CGRect(x: 0, y: 0, width: width, height: height)) } } -
如何把触摸在imageView上的坐标转换为UIImage上的种子点
由于UIImageView的大小也UIImage得大小是不一样的,所以当我们获取手势在ImageView上的坐标的时候,要经过变换得到UIImage上的坐标,把此点作为种子点入栈
//注,self是UIImageView的子类
override func touchesBegan(_ touches: Set, with event: UIEvent?) {
if touches.count == 1 , let touch = touches.first , let imageRef = self.image?.cgImage{
let point = touch.location(in: self)
let width = imageRef.width
let height = imageRef.height
let widthScale = CGFloat(width) / bounds.width
let heightScale = CGFloat(height) / bounds.height
//把相对于view的touch point 转换成image的像素点的坐标点
let realPoint = CGPoint(x: point.x * widthScale, y: point.y * heightScale)
}
}
-
实现扫描线种子填充算法
核心代码如下,以下代码是写在自定义的UIImageView的子类中
//MARK: private method
/// 填充颜色
///
/// - Parameters:
/// - point: 种子点
/// - color: 填充颜色
private func floodFill(from point:CGPoint) {
if let imageRef = image?.cgImage {
let width = imageRef.width
let height = imageRef.height
let widthScale = CGFloat(width) / bounds.width
let heightScale = CGFloat(height) / bounds.height
//把相对于view的touch point 转换成image的像素点的坐标点
let realPoint = CGPoint(x: point.x * widthScale, y: point.y * heightScale)
scanedLines = [:]
imageSize = CGSize(width: width, height: height)
pixels = Array(repeating: 0, count: width * height)
let colorSpace = CGColorSpaceCreateDeviceRGB() //像素点的颜色空间
let bitsPerComponent = 8 //颜色空间每个通道占用的bit
let bytesPerRow = width * 4 //image每一行所占用的byte
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
if let context = CGContext(data: &(pixels), width: width, height: height, bitsPerComponent: bitsPerComponent,
bytesPerRow: bytesPerRow, space: colorSpace, bitmapInfo: bitmapInfo) {
context.draw(imageRef, in: CGRect(x: 0, y: 0, width: width, height: height))
let pixelIndex = lrintf(Float(realPoint.y)) * width + lrintf(Float(realPoint.x))
let newColorRgbaValue = newColor.rgbaValue
let colorRgbaValue = pixels[pixelIndex]
//如果点击的黑色边框,直接退出
if isBlackColor(color: colorRgbaValue) {
return
}
//如果点击的颜色和新颜色一样,退出
if compareColor(color: colorRgbaValue, otherColor: newColorRgbaValue, tolorance: colorTolorance) {
return
}
//存放种子点的栈
seedPointList.push(realPoint)
while !seedPointList.isEmpty {
if let point = seedPointList.pop() {
let (xLeft,xRight) = fillLine(seedPoint: point, newColorRgbaValue: newColorRgbaValue,
originalColorRgbaValue: colorRgbaValue)
scanLine(lineNumer: Int(point.y) + 1, xLeft: xLeft, xRight: xRight, originalColorRgbaValue: colorRgbaValue)
scanLine(lineNumer: Int(point.y) - 1, xLeft: xLeft, xRight: xRight, originalColorRgbaValue: colorRgbaValue)
}
}
if let cgImage = context.makeImage() {
image = UIImage(cgImage: cgImage, scale: image?.scale ?? 2, orientation: .up)
}
}
}
}
/// 通过种子点向左向右填充
///
/// - Parameters:
/// - seedPoint: 种子点
/// - newColorRgbaValue: 填充的新颜色的值
/// - originalColorRgbaValue: 触摸点颜色的值
/// - Returns: 种子点填充的左右区间 都是闭区间
private func fillLine(seedPoint:CGPoint,newColorRgbaValue:UInt32,originalColorRgbaValue:UInt32) -> (Int,Int) {
let imageW = Int(imageSize.width)
let currntLineMinIndex = Int(seedPoint.y) * imageW
let currntLineMaxIndex = currntLineMinIndex + imageW
let currentPixelIndex = currntLineMinIndex + Int(seedPoint.x)
var xleft = Int(seedPoint.x)
var xright = xleft
if pixels.count >= currntLineMaxIndex {
var tmpIndex = currentPixelIndex
while tmpIndex >= currntLineMinIndex &&
compareColor(color: originalColorRgbaValue, otherColor: pixels[tmpIndex], tolorance: colorTolorance){
pixels[tmpIndex] = newColorRgbaValue
tmpIndex -= 1
xleft -= 1
}
tmpIndex = currentPixelIndex + 1
while tmpIndex < currntLineMaxIndex &&
compareColor(color: originalColorRgbaValue, otherColor: pixels[tmpIndex], tolorance: colorTolorance){
pixels[tmpIndex] = newColorRgbaValue
tmpIndex += 1
xright += 1
}
}
return (xleft + 1,xright)
}
/// 从xLeft到xRight的扫描第lineNumer行
///
/// - Parameters:
/// - lineNumer: 行数
/// - xLeft: 扫描线的最左侧
/// - xRight: 扫描线的最右侧
/// - originalColorRgbaValue: 触摸点颜色的值
private func scanLine(lineNumer:Int,xLeft:Int,xRight:Int,originalColorRgbaValue:UInt32) {
if lineNumer < 0 || CGFloat(lineNumer) > imageSize.height - 1{
return
}
var xCurrent = xLeft //当前被扫描的点的x位置
let currentLineOriginalIndex = lineNumer * Int(imageSize.width)
var currentPixelIndex = currentLineOriginalIndex + xLeft //当前被扫描的点的所在像素点的位置
var currntLineMaxIndex = currentLineOriginalIndex + xRight //当前扫描线需要扫描的最后一个点的位置
//此处是对种子扫描线算法的一点小优化
var leftSpiltIndex:Int?
if var scanLine = scanedLines[lineNumer] {
if scanLine.xLeft >= xRight || scanLine.xRight <= xLeft {//没有相交,什么也不做
}else if scanLine.xLeft <= xLeft && scanLine.xRight >= xRight { //旧扫描与新扫描的范围关系是包含
return
}else if scanLine.xLeft <= xLeft && scanLine.xRight <= xRight {//旧扫描与新扫描的范围关系是左包含右被包含
xCurrent = scanLine.xRight + 1
currentPixelIndex = currentLineOriginalIndex + scanLine.xRight + 1
scanLine.xRight = xRight
scanedLines[lineNumer] = scanLine
}else if scanLine.xLeft >= xLeft && scanLine.xRight >= xRight {//旧扫描与新扫描的范围关系是左被包含右包含
currntLineMaxIndex = currentLineOriginalIndex + scanLine.xLeft - 1
leftSpiltIndex = currentLineOriginalIndex + scanLine.xLeft
scanLine.xLeft = xLeft
scanedLines[lineNumer] = scanLine
}else if scanLine.xLeft >= xLeft && scanLine.xRight <= xRight {//旧扫描与新扫描的范围关系是被包含
scanLine.xLeft = xLeft
scanLine.xRight = xRight
scanedLines[lineNumer] = scanLine
}
}else {
scanedLines[lineNumer] = FillLineInfo(lineNumber: lineNumer, xLeft: xLeft, xRight: xRight)
}
while currentPixelIndex <= currntLineMaxIndex {
var isFindSeed = false
//找到此区间的种子点,种子点是存在非边界且未填充的像素点,这些相邻的像素点中最右边的一个
while currentPixelIndex < currntLineMaxIndex &&
compareColor(color: originalColorRgbaValue, otherColor: pixels[currentPixelIndex], tolorance: colorTolorance) {
isFindSeed = true
currentPixelIndex += 1
xCurrent += 1
}
if isFindSeed {
//如果找到种子点,需要判断while循环的退出条件是什么
//如果是到区间最右边的倒数第二个点,则需要判断最右边的点是否和originalColorRgbaValue颜色一样,如果一样,则最右边的入栈,否则把上一个点入栈
//如果是碰到了边界点退出的,则把当前点的上一个点入栈
if compareColor(color: originalColorRgbaValue, otherColor: pixels[currentPixelIndex], tolorance: colorTolorance) &&
currentPixelIndex == currntLineMaxIndex {
//若当旧扫描与新扫描的范围关系是左被包含右包含,需要扫描的范围应该是新扫描范围的左点到旧扫描范围的左点的上一个点
//此时若扫描范围内的最右点颜色与originalColorRgbaValue一样,并且旧扫描范围的左点的颜色也与originalColorRgbaValue一样,则不需要入栈
if leftSpiltIndex == nil ||
!compareColor(color: originalColorRgbaValue, otherColor: pixels[leftSpiltIndex!], tolorance: colorTolorance){
seedPointList.push(CGPoint(x: xCurrent, y: lineNumer))
}
}else {
seedPointList.push(CGPoint(x: xCurrent - 1, y: lineNumer))
}
}
currentPixelIndex += 1
xCurrent += 1
}
}
/// 判断颜色是否是黑色
///
/// - Returns: true 是 or false 不是
private func isBlackColor(color:UInt32) -> Bool {
let colorRed = Int((color >> 0) & 0xff)
let colorGreen = Int((color >> 8) & 0xff)
let colorBlue = Int((color >> 16) & 0xff)
let colorAlpha = Int((color >> 24) & 0xff)
if colorRed < colorTolorance &&
colorGreen < colorTolorance &&
colorBlue < colorTolorance &&
colorAlpha > 255 - colorTolorance{
return true
}
return false
}
/// 是否是相似的颜色
///
/// - Returns: true 相似 or false 不相似
private func compareColor(color:UInt32, otherColor:UInt32, tolorance:Int) -> Bool {
if color == otherColor {
return true
}
let colorRed = Int((color >> 0) & 0xff)
let colorGreen = Int((color >> 8) & 0x00ff)
let colorBlue = Int((color >> 16) & 0xff)
let colorAlpha = Int((color >> 24) & 0xff)
let otherColorRed = Int((otherColor >> 0) & 0xff)
let otherColorGreen = Int((otherColor >> 8) & 0xff)
let otherColorBlue = Int((otherColor >> 16) & 0xff)
let otherColorAlpha = Int((otherColor >> 24) & 0xff)
if abs(colorRed - otherColorRed) > tolorance ||
abs(colorGreen - otherColorGreen) > tolorance ||
abs(colorBlue - otherColorBlue) > tolorance ||
abs(colorAlpha - otherColorAlpha) > tolorance {
return false
}
return true
}
extension UIColor {
/// 获取颜色的UInt32表示形式
fileprivate var rgbaValue:UInt32 {
var red:CGFloat = 0
var green:CGFloat = 0
var blue:CGFloat = 0
var alpha:CGFloat = 0
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return UInt32(red * 255) << 0 | UInt32(green * 255) << 8 | UInt32(blue * 255) << 16 | UInt32(alpha * 255) << 24
}
}
-
做此功能的一些其他收获
-
UIScrollView很容易实现视图的缩放功能,只要在代理方法中返回需要缩放的视图即可,UIScrollView是如何实现子视图的缩放的?
UIScrollView是通过改变子视图的transform来实现缩放的
-
当使用transform把视图缩放后,frame和bounds会如何变化,该视图的子视图的frame和bounds又会如何变化
frame会同比缩放,而bounds不会变化,子视图的frame和bounds都不变
原因猜测(纯属猜测)如下:frame.size代表的是视图的大小,这个大小是逻辑大小,而不是真正的像素大小。而bounds也是逻辑大小。以iphone6举例,在无缩放的情况下,frame.size.width = 1代表着2个像素点,在缩放的过程中。当前缩放的视图的frame.size每个逻辑大小对应的像素点不变,而bounds.size每个逻辑大小对应的像素点则同比缩放,对于子视图来说,frame.size每个逻辑大小对应的像素点等于父视图的bounds.size每个逻辑大小对应的像素点,bounds.size每个逻辑大小对应的像素点则等于父视图的bounds.size每个逻辑大小对应的像素点和自身的缩放的乘积 -
当使用transform把视图放大之后,触摸点point的范围是否会放大,也就是说如果放大之前视图的大小为375*373,point的范围为(0,0)-(375,375),那么放大2倍后,point的范围是(0,0)-(375,375)还是(0,0)-(750,750)
范围还是(0,0)-(375,375),原因猜测如下:手势获取坐标的时候是基于视图的bounds的
-
-
demo的GitHub地址
-
参考文章
- 多边形区域填充算法--扫描线种子填充算法
- 谈谈 iOS 中图片的解压缩