iOS 用扫描线种子填充算法实现涂鸦的功能

扫描线种子填充算法基本步骤:

  1. 初始化一个空栈用于存放种子点,将种子点(x,y)入栈
  2. 判断栈是否为空,如果栈为空则算法结束,否则取出栈顶元素作为当前扫描线的种子点(x,y),y是当前的扫描线
  3. 从种子点(x,y)出发,沿当前扫描线向左向右两个方向填充,直到边界。分别标记区段的左右端点为xLeft,xRight
  4. 分别检查与当前扫描线相邻的y-1和y+1两条扫描线在区间[xLeft,xRight]中的像素,从xLeft开始xRight方向搜索,若存在非边界且未填充的像素点,则找出这些相邻像素点中最右边的一个,并将其作为种子点入栈,然后返回第2步(注:一条扫描线上可能存在多个种子点)

涂鸦效果

未命名.gif

iOS中如何实现扫描线种子填充算法

  1. 扫描的是什么东东?

    扫描的是图片上所有的像素点的集合,而常用的png,jpg是压缩过的位图,所以首先要把png,jpg图片进行解压缩

  2. 在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<UInt32>(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))
            }
    }
    
  3. 如何把触摸在imageView上的坐标转换为UIImage上的种子点

    由于UIImageView的大小也UIImage得大小是不一样的,所以当我们获取手势在ImageView上的坐标的时候,要经过变换得到UIImage上的坐标,把此点作为种子点入栈

//注,self是UIImageView的子类
 override func touchesBegan(_ touches: Set<UITouch>, 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)
        }
    }
  1. 实现扫描线种子填充算法

核心代码如下,以下代码是写在自定义的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<UInt32>(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
    }
}
  1. 做此功能的一些其他收获

    • 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的

  2. demo的GitHub地址

  3. 参考文章

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268