从用户的触摸绘制一个完美的圆形

187

我有一个练习项目,允许用户在触摸屏幕时用手指绘图。这是我以前做的一个非常简单的应用程序。 我的小表弟拿着我的iPad,在这个应用程序上用手指画东西(孩子的画:圆、线等等,任何想到的东西)。 然后他开始画圆,然后要求我把它变成一个“好的圆”(据我理解:使画出的圆形完美地圆形,因为我们都知道,无论我们如何稳定地在屏幕上用手指绘制某些东西,一个圆永远不会真正像圆应该那样圆)。

所以我的问题是,在代码中是否有办法首先检测用户绘制成圆形的线条,并通过使其在屏幕上变得完美圆形而生成大致相同大小的圆形。使一条不太直的线变直是我知道如何做的事情,但是对于圆形,我不太清楚如何使用Quartz或其他方法去做。

我的想法是,用户抬起手指之后,线条的起点和终点必须接触或交叉,以证明他实际上正在试图画一个圆。


2
在这种情况下,很难区分圆和多边形之间的区别。那么有一个"圆形工具"怎么样?用户单击以定义圆心或边界矩形的一个角,然后拖动以更改半径或设置相反的角。 - user1118321
2
@user1118321:这样做违背了只需画一个圆就能得到完美圆形的概念。理想情况下,应用程序应该仅通过用户的绘图就能识别出用户是画了一个圆(或者近似圆)、椭圆还是多边形。(此外,多边形可能不在此应用程序的范围内——它可能只包括圆形或直线。) - Peter Hosey
那么,你认为我应该给谁答案赏金呢?我看到很多优秀的候选人。 - Peter Hosey
@Unheilig:除了对三角函数有初步的理解外,我在这个主题上没有任何专业知识。话虽如此,但在我看来,显示出最大潜力的答案是https://dev59.com/vmMk5IYBdhLWcg3w0hI-#19071980,https://dev59.com/vmMk5IYBdhLWcg3w0hI-#19055873,https://dev59.com/vmMk5IYBdhLWcg3w0hI-#18995771,也许是https://dev59.com/vmMk5IYBdhLWcg3w0hI-#18992200和我的答案。这些是我首先尝试的答案。我把顺序留给你。 - Peter Hosey
请尝试在谷歌上搜索伊万·萨瑟兰的“Skechpad”!这个问题已经在1963年得到解决了! - Gene
2
@Gene: 也许你可以总结相关信息,并在答案中提供更多详细信息的链接。 - Peter Hosey
8个回答

418
有时候重新发明轮子真的很有用。您可能已经注意到有很多框架,但是不引入所有这些复杂性,实现一个简单但有用的解决方案并不那么困难。(请不要误解,对于任何严肃的目的,最好使用一些成熟且被证明稳定的框架)。
我将首先介绍我的结果,然后解释背后的简单明了的想法。

enter image description here

你会在我的实现中发现,没有必要分析每一个点并进行复杂的计算。这个想法是发现一些有价值的元信息。我将以 tangent 为例:

enter image description here

让我们识别一个简单明了的模式,它是所选形状的典型特征:

enter image description here

因此,基于这个想法实现圆形检测机制并不难。请查看下面的工作演示(抱歉,我使用Java作为提供快速和有点粗糙示例的最快方式):

import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.HeadlessException;
import java.awt.Point;
import java.awt.RenderingHints;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseMotionListener;
import java.util.ArrayList;
import java.util.List;
import javax.swing.JFrame;
import javax.swing.SwingUtilities;

public class CircleGestureDemo extends JFrame implements MouseListener, MouseMotionListener {

    enum Type {
        RIGHT_DOWN,
        LEFT_DOWN,
        LEFT_UP,
        RIGHT_UP,
        UNDEFINED
    }

    private static final Type[] circleShape = {
        Type.RIGHT_DOWN,
        Type.LEFT_DOWN,
        Type.LEFT_UP,
        Type.RIGHT_UP};

    private boolean editing = false;
    private Point[] bounds;
    private Point last = new Point(0, 0);
    private List<Point> points = new ArrayList<>();

    public CircleGestureDemo() throws HeadlessException {
        super("Detect Circle");

        addMouseListener(this);
        addMouseMotionListener(this);
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

        setPreferredSize(new Dimension(800, 600));
        pack();
    }

    @Override
    public void paint(Graphics graphics) {
        Dimension d = getSize();
        Graphics2D g = (Graphics2D) graphics;

        super.paint(g);

        RenderingHints qualityHints = new RenderingHints(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        qualityHints.put(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
        g.setRenderingHints(qualityHints);

        g.setColor(Color.RED);
        if (cD == 0) {
            Point b = null;
            for (Point e : points) {
                if (null != b) {
                    g.drawLine(b.x, b.y, e.x, e.y);
                }
                b = e;
            }
        }else if (cD > 0){
            g.setColor(Color.BLUE);
            g.setStroke(new BasicStroke(3));
            g.drawOval(cX, cY, cD, cD);
        }else{
            g.drawString("Uknown",30,50);
        }
    }


    private Type getType(int dx, int dy) {
        Type result = Type.UNDEFINED;

        if (dx > 0 && dy < 0) {
            result = Type.RIGHT_DOWN;
        } else if (dx < 0 && dy < 0) {
            result = Type.LEFT_DOWN;
        } else if (dx < 0 && dy > 0) {
            result = Type.LEFT_UP;
        } else if (dx > 0 && dy > 0) {
            result = Type.RIGHT_UP;
        }

        return result;
    }

    private boolean isCircle(List<Point> points) {
        boolean result = false;
        Type[] shape = circleShape;
        Type[] detected = new Type[shape.length];
        bounds = new Point[shape.length];

        final int STEP = 5;

        int index = 0;        
        Point current = points.get(0);
        Type type = null;

        for (int i = STEP; i < points.size(); i += STEP) {
            Point next = points.get(i);
            int dx = next.x - current.x;
            int dy = -(next.y - current.y);

            if(dx == 0 || dy == 0) {
                continue;
            }

            Type newType = getType(dx, dy);
            if(type == null || type != newType) {
                if(newType != shape[index]) {
                    break;
                }
                bounds[index] = current;
                detected[index++] = newType;
            }
            type = newType;            
            current = next;

            if (index >= shape.length) {
                result = true;
                break;
            }
        }

        return result;
    }

    @Override
    public void mousePressed(MouseEvent e) {
        cD = 0;
        points.clear();
        editing = true;
    }

    private int cX;
    private int cY;
    private int cD;

    @Override
    public void mouseReleased(MouseEvent e) {
        editing = false;
        if(points.size() > 0) {
            if(isCircle(points)) {
                cX = bounds[0].x + Math.abs((bounds[2].x - bounds[0].x)/2);
                cY = bounds[0].y;
                cD = bounds[2].y - bounds[0].y;
                cX = cX - cD/2;

                System.out.println("circle");
            }else{
                cD = -1;
                System.out.println("unknown");
            }
            repaint();
        }
    }

    @Override
    public void mouseDragged(MouseEvent e) {
        Point newPoint = e.getPoint();
        if (editing && !last.equals(newPoint)) {
            points.add(newPoint);
            last = newPoint;
            repaint();
        }
    }

    @Override
    public void mouseMoved(MouseEvent e) {
    }

    @Override
    public void mouseEntered(MouseEvent e) {
    }

    @Override
    public void mouseExited(MouseEvent e) {
    }

    @Override
    public void mouseClicked(MouseEvent e) {
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(new Runnable() {

            @Override
            public void run() {
                CircleGestureDemo t = new CircleGestureDemo();
                t.setVisible(true);
            }
        });
    }
}

在iOS上实现类似的行为不应该是问题,因为你只需要几个事件和坐标信息。类似以下代码(参见example):

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    UITouch* touch = [[event allTouches] anyObject];
}

- (void)handleTouch:(UIEvent *)event {
    UITouch* touch = [[event allTouches] anyObject];
    CGPoint location = [touch locationInView:self];

}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    [self handleTouch: event];
}

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
    [self handleTouch: event];    
}

有几种增强可能。

从任意点开始

当前要求是从顶部中间点开始绘制圆形,原因是为了以下的简化:

        if(type == null || type != newType) {
            if(newType != shape[index]) {
                break;
            }
            bounds[index] = current;
            detected[index++] = newType;
        }

请注意默认值index的使用。通过对形状可用的“部分”进行简单搜索,可以消除该限制。请注意,您需要使用循环缓冲区才能检测完整的形状:

enter image description here

顺时针和逆时针

为了支持两种模式,您需要使用之前增强的循环缓冲区,并在两个方向上进行搜索:

enter image description here

绘制椭圆

你已经拥有了所需的一切,都在bounds数组中。

enter image description here

只需使用那些数据:

cWidth = bounds[2].y - bounds[0].y;
cHeight = bounds[3].y - bounds[1].y;

其他手势(可选)

最后,您只需要正确处理当dx(或dy)等于零的情况,以支持其他手势:

enter image description here

更新

这个小的概念验证受到了很高的关注,因此我稍微更新了代码,以使其能够平稳运行并提供一些绘图提示、突出显示支持点等等:

enter image description here

这里是代码:

import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.HeadlessException;
import java.awt.Point;
import java.awt.RenderingHints;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseMotionListener;
import java.util.ArrayList;
import java.util.List;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;

public class CircleGestureDemo extends JFrame {

    enum Type {

        RIGHT_DOWN,
        LEFT_DOWN,
        LEFT_UP,
        RIGHT_UP,
        UNDEFINED
    }

    private static final Type[] circleShape = {
        Type.RIGHT_DOWN,
        Type.LEFT_DOWN,
        Type.LEFT_UP,
        Type.RIGHT_UP};

    public CircleGestureDemo() throws HeadlessException {
        super("Circle gesture");
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        setLayout(new BorderLayout());
        add(BorderLayout.CENTER, new GesturePanel());
        setPreferredSize(new Dimension(800, 600));
        pack();
    }

    public static class GesturePanel extends JPanel implements MouseListener, MouseMotionListener {

        private boolean editing = false;
        private Point[] bounds;
        private Point last = new Point(0, 0);
        private final List<Point> points = new ArrayList<>();

        public GesturePanel() {
            super(true);
            addMouseListener(this);
            addMouseMotionListener(this);
        }

        @Override
        public void paint(Graphics graphics) {
            super.paint(graphics);

            Dimension d = getSize();
            Graphics2D g = (Graphics2D) graphics;

            RenderingHints qualityHints = new RenderingHints(RenderingHints.KEY_ANTIALIASING,
                    RenderingHints.VALUE_ANTIALIAS_ON);
            qualityHints.put(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);

            g.setRenderingHints(qualityHints);

            if (!points.isEmpty() && cD == 0) {
                isCircle(points, g);
                g.setColor(HINT_COLOR);
                if (bounds[2] != null) {
                    int r = (bounds[2].y - bounds[0].y) / 2;
                    g.setStroke(new BasicStroke(r / 3 + 1));
                    g.drawOval(bounds[0].x - r, bounds[0].y, 2 * r, 2 * r);
                } else if (bounds[1] != null) {
                    int r = bounds[1].x - bounds[0].x;
                    g.setStroke(new BasicStroke(r / 3 + 1));
                    g.drawOval(bounds[0].x - r, bounds[0].y, 2 * r, 2 * r);
                }
            }

            g.setStroke(new BasicStroke(2));
            g.setColor(Color.RED);

            if (cD == 0) {
                Point b = null;
                for (Point e : points) {
                    if (null != b) {
                        g.drawLine(b.x, b.y, e.x, e.y);
                    }
                    b = e;
                }

            } else if (cD > 0) {
                g.setColor(Color.BLUE);
                g.setStroke(new BasicStroke(3));
                g.drawOval(cX, cY, cD, cD);
            } else {
                g.drawString("Uknown", 30, 50);
            }
        }

        private Type getType(int dx, int dy) {
            Type result = Type.UNDEFINED;

            if (dx > 0 && dy < 0) {
                result = Type.RIGHT_DOWN;
            } else if (dx < 0 && dy < 0) {
                result = Type.LEFT_DOWN;
            } else if (dx < 0 && dy > 0) {
                result = Type.LEFT_UP;
            } else if (dx > 0 && dy > 0) {
                result = Type.RIGHT_UP;
            }

            return result;
        }

        private boolean isCircle(List<Point> points, Graphics2D g) {
            boolean result = false;
            Type[] shape = circleShape;
            bounds = new Point[shape.length];

            final int STEP = 5;
            int index = 0;
            int initial = 0;
            Point current = points.get(0);
            Type type = null;

            for (int i = STEP; i < points.size(); i += STEP) {
                final Point next = points.get(i);
                final int dx = next.x - current.x;
                final int dy = -(next.y - current.y);

                if (dx == 0 || dy == 0) {
                    continue;
                }

                final int marker = 8;
                if (null != g) {
                    g.setColor(Color.BLACK);
                    g.setStroke(new BasicStroke(2));
                    g.drawOval(current.x - marker/2, 
                               current.y - marker/2, 
                               marker, marker);
                }

                Type newType = getType(dx, dy);
                if (type == null || type != newType) {
                    if (newType != shape[index]) {
                        break;
                    }
                    bounds[index++] = current;
                }

                type = newType;
                current = next;
                initial = i;

                if (index >= shape.length) {
                    result = true;
                    break;
                }
            }
            return result;
        }

        @Override
        public void mousePressed(MouseEvent e) {
            cD = 0;
            points.clear();
            editing = true;
        }

        private int cX;
        private int cY;
        private int cD;

        @Override
        public void mouseReleased(MouseEvent e) {
            editing = false;
            if (points.size() > 0) {
                if (isCircle(points, null)) {
                    int r = Math.abs((bounds[2].y - bounds[0].y) / 2);
                    cX = bounds[0].x - r;
                    cY = bounds[0].y;
                    cD = 2 * r;
                } else {
                    cD = -1;
                }
                repaint();
            }
        }

        @Override
        public void mouseDragged(MouseEvent e) {
            Point newPoint = e.getPoint();
            if (editing && !last.equals(newPoint)) {
                points.add(newPoint);
                last = newPoint;
                repaint();
            }
        }

        @Override
        public void mouseMoved(MouseEvent e) {
        }

        @Override
        public void mouseEntered(MouseEvent e) {
        }

        @Override
        public void mouseExited(MouseEvent e) {
        }

        @Override
        public void mouseClicked(MouseEvent e) {
        }
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(new Runnable() {

            @Override
            public void run() {
                CircleGestureDemo t = new CircleGestureDemo();
                t.setVisible(true);
            }
        });
    }

    final static Color HINT_COLOR = new Color(0x55888888, true);
}

85
非常精彩的回答,Renat。方法描述清晰,有记录过程的图像和动画。看起来也是最通用、最强大的解决方案。其他相关想法听起来非常聪明,就像最初的(当前的?)手写识别技术一样。为了这个答案,我会将这个问题加星标保存。 :) - enhzflep
我想要补充的是,在Cocoa Touch中,UIGestureRecognizers的状态机特性使其成为实现此功能的完美场所。您可以为每个形状设置一个识别器,甚至(我想象中)为顺时针和逆时针分别设置不同的识别器。手势识别器的本质是在手势进行过程中逐渐排除自身,因此到最后,您应该只剩下零个或一个识别器——如果是一个,那就是用户刚刚绘制的形状,然后控制器只需要询问它在哪里以及有多大即可。(警告:我从未实现过自定义手势识别器。) - Peter Hosey
30
更一般地说,一个理解简明的解释、图示、动画演示、代码和变化?这是一个理想的 Stack Overflow 回答。 - Peter Hosey
12
这个回答太好了,我几乎可以原谅他在Java中做计算机图形!;) - Nicolas Miari
4
圣诞老人雷纳特,今年圣诞节还会有更多令人惊喜的更新吗?例如更多形状等等。 :-) - Unheilig
1
哇,真给力。 - wogsland

16
一种用于检测形状的经典计算机视觉技术是霍夫变换。霍夫变换的优点之一是它非常容忍部分数据、不完美数据和噪声。使用霍夫变换检测圆形:http://en.wikipedia.org/wiki/Hough_transform#Circle_detection_process 考虑到你的圆是手绘的,我认为霍夫变换可能适合您。
以下是“简化”的解释,抱歉它并不那么简单。其中许多内容来自我多年前做的一个学校项目。
霍夫变换是一种投票方案。分配一个二维整数数组,并将所有元素设置为零。每个元素对应于正在分析的图像中的单个像素。由于每个元素都将累积信息(即投票),表示该像素可能在圆或弧的原点处,因此将此数组称为累加器数组。
应用梯度算子边缘检测器到图像上并记录边缘像素或edgel。Edgel是一个像素,相对于它的邻居具有不同的强度或颜色。差异程度称为梯度幅值。对于达到足够幅值的每个edgel,应用投票方案将累加器数组的元素递增。被递增(投票)的元素对应于考虑中的edgel通过的可能圆的起点。期望结果是,如果存在弧,则真实起点将获得比错误起点更多的选票。
请注意,正在访问进行投票的累加器数组元素形成了围绕考虑中的edgel的圆。计算要投票的x、y坐标与计算正在绘制的圆的x、y坐标相同。
在手绘图像中,您可以直接使用集合(着色)像素而不是计算edgels。
现在,由于像素位置不完美,您不一定会获得具有最大投票数的单个累加器数组元素。您可能会得到一组具有大量投票的相邻数组元素,即群集。该群集的重心可能提供起点的良好近似值。
请注意,您可能需要针对不同的半径R运行Hough变换。产生更密集的投票簇的那个是“更好”的拟合。
有各种技术可用于减少虚假起源的投票。例如,使用edgels的一个优点是它们不仅具有大小,而且还具有方向。在投票时,我们只需要投票适当方向的可能起源。接收投票的位置将形成一个弧而不是一个完整的圆。
以下是一个示例。我们从半径为1的圆和初始化的累加器数组开始。考虑每个像素作为潜在起源进行投票。真正的起源获得了最多的投票,本例中是四票。
.  empty pixel
X  drawn pixel
*  drawn pixel currently being considered

. . . . .   0 0 0 0 0
. . X . .   0 0 0 0 0
. X . X .   0 0 0 0 0
. . X . .   0 0 0 0 0
. . . . .   0 0 0 0 0

. . . . .   0 0 0 0 0
. . X . .   0 1 0 0 0
. * . X .   1 0 1 0 0
. . X . .   0 1 0 0 0
. . . . .   0 0 0 0 0

. . . . .   0 0 0 0 0
. . X . .   0 1 0 0 0
. X . X .   1 0 2 0 0
. . * . .   0 2 0 1 0
. . . . .   0 0 1 0 0

. . . . .   0 0 0 0 0
. . X . .   0 1 0 1 0
. X . * .   1 0 3 0 1
. . X . .   0 2 0 2 0
. . . . .   0 0 1 0 0

. . . . .   0 0 1 0 0
. . * . .   0 2 0 2 0
. X . X .   1 0 4 0 1
. . X . .   0 2 0 2 0
. . . . .   0 0 1 0 0

6

这里有另一种方法。使用UIView touchesBegan、touchesMoved、touchesEnded并将点添加到数组中。您将数组分成两半,并测试一个数组中的每个点是否与另一个数组中其对应点的直径大致相同,就像所有其他对一样。

    NSMutableArray * pointStack;

    - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
    {
        // Detect touch anywhere
    UITouch *touch = [touches anyObject];


    pointStack = [[NSMutableArray alloc]init];

    CGPoint touchDownPoint = [touch locationInView:touch.view];


    [pointStack addObject:touchDownPoint];

    }


    /**
     * 
     */
    - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
    {

            UITouch* touch = [touches anyObject];
            CGPoint touchDownPoint = [touch locationInView:touch.view];

            [pointStack addObject:touchDownPoint];  

    }

    /**
     * So now you have an array of lots of points
     * All you have to do is find what should be the diameter
     * Then compare opposite points to see if the reach a similar diameter
     */
    - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
    {
            uint pointCount = [pointStack count];

    //assume the circle was drawn a constant rate and the half way point will serve to calculate or diameter
    CGPoint startPoint = [pointStack objectAtIndex:0];
    CGPoint halfWayPoint = [pointStack objectAtIndex:floor(pointCount/2)];

    float dx = startPoint.x - halfWayPoint.x;
    float dy = startPoint.y - halfWayPoint.y;


    float diameter = sqrt((dx*dx) + (dy*dy));

    bool isCircle = YES;// try to prove false!

    uint indexStep=10; // jump every 10 points, reduce to be more granular

    // okay now compare matches
    // e.g. compare indexes against their opposites and see if they have the same diameter
    //
      for (uint i=indexStep;i<floor(pointCount/2);i+=indexStep)
      {

      CGPoint testPointA = [pointStack objectAtIndex:i];
      CGPoint testPointB = [pointStack objectAtIndex:floor(pointCount/2)+i];

      dx = testPointA.x - testPointB.x;
      dy = testPointA.y - testPointB.y;


      float testDiameter = sqrt((dx*dx) + (dy*dy));

      if(testDiameter>=(diameter-10) && testDiameter<=(diameter+10)) // +/- 10 ( or whatever degree of variance you want )
      {
      //all good
      }
      else
      {
      isCircle=NO;
      }

    }//end for loop

    NSLog(@"iCircle=%i",isCircle);

}

听起来可以吗? :)

3
我不是形状识别专家,但这是我处理问题的方法。
首先,在显示用户路径时要自由绘制,同时秘密累积一系列点(x,y)样本以及时间。您可以从拖动事件中获得这两个数据,并将它们封装到一个简单的模型对象中,并将其放入可变数组中。
您可能希望相当频繁地进行采样-比如每0.1秒。另一个可能性是开始非常频繁,比如每0.05秒,然后观察用户拖动的时间;如果他们拖了一段时间,那么降低采样频率(并且舍弃任何可能被忽略的样本),将其调整为0.2秒之类的值。
(不要将我的数字视为定论,因为我只是根据经验提出的建议。请自行实验,找到更好的值。)
其次,分析样本。
需要推导出两个事实。首先,形状的中心,它(如果我没记错)应该只是所有点的平均值。第二,每个样本距离该中心的平均半径。
如果像@user1118321所猜测的那样,要支持多边形,则其余的分析就是做出这个决定:用户是否想绘制圆形或多边形。您可以先将样本视为多边形,以作出这个决定。
有几个标准可以使用:
时间:如果用户在某些点上停留的时间比其他点长(如果采样间隔恒定,则会在空间中附近成组连续采样),那么它们可能是角落。您应该使角落门槛很小,以便用户可以无意识地做到这一点,而不必每次都刻意暂停。
角度:圆形的角度从一个样本到下一个样本大致相同。多边形将具有几个由直线段连接的角度;角落是这些角度。对于正多边形(不规则多边形的椭圆形),所有角落的角度应该大致相同;不规则的多边形将具有不同的角度。
间隔:正多边形的角落在角度维度上等距分布,并且半径是恒定的。不规则多边形将具有不规则的角度间隔和/或非恒定半径。
第三步也是最后一步是创建形状,在先前确定的中心点周围,并带有之前确定的半径。
不能保证我以上说的任何事情会起作用或高效,但我希望它至少能让您走上正确的道路-如果任何比我更了解形状识别的人(这是一个非常低的门槛)看到了这一点,请随时发表评论或您自己的答案。

+1 嗨,感谢您的输入。非常有启发性。同样地,我希望iOS/"形状识别"超人能够看到这篇文章并进一步启迪我们。 - Unheilig
1
你的算法听起来不错。我会添加一个检查,看看用户的路径与完美圆形/多边形相差多远(例如,百分比均方偏差)。如果太大,用户可能不想要理想的形状。对于技艺娴熟的涂鸦者,截止值会比邋遢的涂鸦者小。有了这个功能,程序可以给艺术家提供艺术自由,但对初学者提供很多帮助。 - dmm
@user2654818:你会如何衡量呢? - Peter Hosey
1
@PeterHosey:关于圆的解释:一旦你有了理想的圆,你就有了中心和半径。所以你要计算每个绘制点与中心的平方距离,即((x-x0)^2 +(y-y0)^2)。从半径的平方中减去它。 (我避免使用大量的平方根来节省计算。)将其称为绘制点的平方误差。对所有绘制点的平方误差求平均值,然后取平方根,再除以半径。这就是您的平均百分偏差。(数学/统计可能令人不快,但在实践中它会起作用。) - dmm

3
一旦您确定用户完成了他们起始点的形状绘制,您可以取样他们通过并尝试将它们拟合到一个圆形。 这个问题有一个MATLAB解决方案: http://www.mathworks.com.au/matlabcentral/fileexchange/15060-fitcircle-m 它基于Walter Gander、Gene H. Golub和Rolf Strebel的论文“最小二乘拟合圆和椭圆”。 http://www.emis.de/journals/BBMS/Bulletin/sup962/gander.pdf 新西兰坎特伯雷大学的Ian Coope博士发表了一篇摘要:
在平面上确定最佳拟合圆的问题(或者明显的n维推广)可以轻松地被表述为一个非线性总最小二乘问题,可以使用高斯牛顿最小化算法来解决。这种直接的方法被证明是低效的,并且对异常值的存在非常敏感。另一种替代的表述方式可以将问题简化为一个线性最小二乘问题,该问题很容易解决。建议采用的方法显示出比非线性最小二乘方法更不敏感于异常值的额外优势。

http://link.springer.com/article/10.1007%2FBF00939613

该 MATLAB 文件可以计算非线性 TLS 和线性 LLS 问题。

2

我使用过一种叫做$1识别器的技术(http://depts.washington.edu/aimgroup/proj/dollar/),在训练良好的情况下效果不错。我用它来识别圆形、直线、三角形和正方形。

虽然这是早期的技术,现在已经有了UIGestureRecognizer,但我认为创建适当的UIGestureRecognizer子类应该也不难。


0

用户触摸的像素是一组x-y坐标。Ian Coope在这里提出了一种线性最小二乘算法,用于非迭代拟合圆形:https://ir.canterbury.ac.nz/handle/10092/11104。其思想是通过简单的变量改变来线性化拟合。

我做了一个简单的Python实现,描述在这里:https://scikit-guess.readthedocs.io/en/latest/generated/skg.nsphere_fit.html

您可以在GitHub上找到源代码:https://github.com/madphysicist/scikit-guess/blob/master/src/skg/nsphere.py。由于该函数只有大约20行长,只要您可以访问允许您反转矩阵的库,就不应该有翻译成您选择的语言的麻烦。事实上,这里描述的问题仅需要反转一个3x3矩阵,您可以使用算术运算手动完成。

这是一个简单的Java实现,特定于2D案例。我没有包括缩放,这可能适用于生产应用程序或如果您有大量像素,但这是一个非常容易的前处理和后处理步骤,留给读者自己练习。
// This is just a container for the result for the example.
// Make it proper with getters and setters if you like.
public class Circle
{
    public final double radius;
    public final double x;
    public final double y;

    public Circle(double radius, double x, double y)
    {
        this.radius = radius;
        this.x = x;
        this.y = y;
    }

    public static fit(int[] x, int[] y)
    {
        // exercise for the reader: check that x.length == y.length

        // To solve b * x = d in terms of least-squares projection
        //   1. bT * b * x = bT * y
        //   2. x = inv(bT * b) * bT * d
        // Matrix b[i] = [x[i], y[i], 1]
        // Vector d[i] = [x[i]*x[i] + y[i]*y[i]]
        long[][] bTb = new long[3][3] = {{0L, 0L, 0L},
                                         {0L, 0L, 0L},
                                         {0L, 0L, 0L}};
        long[] bTd = new long[3] {0L, 0L, 0L};

        for(int i = 0; i < x.length; i++) {
            long x2 = x[i] * x[i];
            long y2 = y[i] * y[i];
            long xy = x[i] * y[i];
            bTb[0][0] += x2;
            bTb[0][1] += xy;
            bTb[1][0] += xy;
            bTb[1][1] += y2;
            bTb[0][2] += x[i];
            bTb[2][0] += x[i];
            bTb[1][2] += y[i];
            bTb[2][1] += y[i];
            bTb[2][2] += 1L;
            long d = x2 + y2;
            bTd[0] += x[i] * d;
            bTd[1] += y[i] * d;
            bTd[2] += d;
        }

        // invert the matrix, e.g.: https://www.wikihow.com/Find-the-Inverse-of-a-3x3-Matrix
        double det_bTb =
            bTb[0][0] * (bTb[1][1] * bTb[2][2] - bTb[2][1] * bTb[1][2]) -
            bTb[0][1] * (bTb[1][0] * bTb[2][2] - bTb[2][0] * bTb[1][2]) +
            bTb[0][2] * (bTb[1][0] * bTb[2][1] - bTb[2][0] * bTb[1][1]);
        // exercise for reader: check if determinant is zero
        double[][] inv_bTb = new double[3][3];
        inv_bTb[0][0] = (double)(bTb[1][1] * bTb[2][2] - bTb[1][2] * bTb[2][1]) / det_bTb;
        inv_bTb[0][1] = (double)(bTb[0][2] * bTb[2][1] - bTb[0][1] * bTb[2][2]) / det_bTb;
        inv_bTb[0][2] = (double)(bTb[0][1] * bTb[1][2] - bTb[0][2] * bTb[1][1]) / det_bTb;
        inv_bTb[1][0] = (double)(bTb[2][0] * bTb[1][2] - bTb[1][0] * bTb[2][2]) / det_bTb;
        inv_bTb[1][1] = (double)(bTb[0][0] * bTb[2][2] - bTb[2][0] * bTb[0][2]) / det_bTb;
        inv_bTb[1][2] = (double)(bTb[1][0] * bTb[0][2] - bTb[0][0] * bTb[1][2]) / det_bTb;
        inv_bTb[2][0] = (double)(bTb[1][0] * bTb[2][1] - bTb[2][0] * bTb[1][1]) / det_bTb;
        inv_bTb[2][1] = (double)(bTb[2][0] * bTb[0][1] - bTb[0][0] * bTb[2][1]) / det_bTb;
        inv_bTb[2][2] = (double)(bTb[0][0] * bTb[1][1] - bTb[0][1] * bTb[1][0]) / det_bTb;

        double[] result = new double[3] {
            bTd[0] * inv_bTb[0][0] + bTd[1] * inv_bTb[0][1] + bTd[2] * inv_bTb[0][2],
            bTd[0] * inv_bTb[1][0] + bTd[1] * inv_bTb[1][1] + bTd[2] * inv_bTb[1][2],
            bTd[0] * inv_bTb[2][0] + bTd[1] * inv_bTb[2][1] + bTd[2] * inv_bTb[2][2]
        };


        return new Circle(Math.sqrt(result[2] +
                               0.25 * result[0] * result[0] +
                               0.25 * result[1] * result[1]),
                          0.5 * result[0], 0.5 * result[1]);
    }
}

这是我手绘的一个圆形样本,当所有黑色像素的坐标被传入时,它的拟合结果如下:

enter image description here


0

这里有一个相当简单的方法,使用:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event

假设这个矩阵网格:
 A B C D E F G H
1      X X
2    X     X 
3  X         X
4  X         X
5    X     X
6      X X
7
8

在“X”位置放置一些UIView,并测试它们是否按顺序被点击。如果它们都按顺序被点击,我认为可以让用户说“干得好,你画了一个圆形”。

听起来不错吧?(而且简单)


嗨,Lemon。思路不错,但在上述情况下,这意味着我们需要有64个UIView来检测触摸,对吧?如果画布的尺寸例如是iPad大小,那么你如何定义一个单一的UIView的尺寸呢?如果圆形很小,而单个UIView的大小较大,则在此情况下我们无法检查序列,因为所有绘制点都位于一个单一的UIView中。 - Unheilig
是的 - 如果你将画布固定为300x300,然后在旁边放置一个“示例”画布,大小与用户要绘制的圆形相同,那么这个方法可能只适用于这种情况。如果是这样,我会选择50x50的正方形6,你还只需要在正确的位置渲染你感兴趣的视图,而不是全部66(36)或8*8(64)。 - dijipiji
@Unheilig:这就是这个解决方案的作用。任何足够圆形以通过正确的视图序列(你可以潜在地允许一些最大的弯路来增加松动度)的东西都会被匹配为一个圆。然后,你将其捕捉到以所有这些视图的中心为中心的完美圆上,其半径达到所有(或至少大多数)视图。 - Peter Hosey
@PeterHosey 好的,让我试着理解一下。如果你们中的任何人能提供一些代码来启动这个过程,我将不胜感激。同时,我也会努力理解这个问题,并在之后做同样的编码部分。谢谢。 - Unheilig
刚刚为您提交了另一种我认为可能更好的方式。 - dijipiji

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接