在图像上布置标签的建议算法/方法

28
给定一张图片和一组标签,这些标签附着在图片上的特定点上。我正在寻找一种算法,将标签布局到图片的两侧,并满足一定的约束条件(每侧标签数量大致相同,标签大致等距离,将标签与它们各自的点连接起来,不交叉)。
现在,一个近似的解决方案通常可以通过按Y坐标(它们所指向的点的坐标)对标签进行排序来很容易地找到,就像这个例子(仅为概念证明,请忽略实际数据的准确性或其他情况!)。
现在,为了满足不交叉的条件,我想到了一些想法:
  • 使用遗传算法找到标签的顺序,确保没有交叉;
  • 使用其他方法(例如动态规划算法)来搜索这样的顺序;
  • 使用上述算法之一,允许间距和顺序的变化,以找到最小化交叉数量和与均匀间距的变化的解决方案;
  • 也许有一些标准可以用来在特定标准内暴力搜索标签的每个可能的排序(如果它们的距离大于X,则不重新排序两个标签);
  • 如果所有其他方法都失败了,只需尝试数百万个随机排序/间距偏移,并选择给出最小交叉/间距变化的那个。 (优点:编程简单,可能会找到足够好的解决方案;稍微劣势,但不是致命的:也许不能在应用程序中实时运行它,以允许用户更改图像的布局/大小。)

在我开始其中一个之前,我想欢迎其他人的意见:是否有人遇到类似问题并且有任何关于上述任何方法成功/失败的信息,或者他们有更好/更简单的解决方案没有发生在我身上?感谢您的参与!


如果我们只谈算法(不是编程语言),您可以逐个绘制线条并保存所有线条(每个点)的x、y坐标。现在,在每条新线上检查每个点的(x,y),如果它们相交,您可以放置一个曲线(看起来像反向“U”形),然后再次在穿过另一条线之后连接您的线。 - Jubin Patel
1
你不觉得实际问题与PCB布线相似吗?有几个明确定义的算法。 - Renat Gilmanov
是的,我之前没有考虑过这种方式,但也许你可以将其概念化为一个类似问题的子集。如果你有一个特定的PCB算法认为可以进行调整,那么你的答案将非常受欢迎。 - Neil Coffey
非常感谢大家对此的贡献 - 很多答案实际上包含了一些有趣的观点,我毫不怀疑会考虑到。 - Neil Coffey
7个回答

14
Lucas Bradsheet的荣誉论文使用多目标进化算法标记地图对此进行了相当好的讨论。
首先,本文为一些标签质量指标创建了可用的度量标准。
例如,清晰度(站点和标签之间映射的明显程度):清晰度(s)=rs+rs1/rt
其中rs是站点和其标签之间的距离,rt是站点和最近其他标签之间的距离)。
它还具有有用的衡量标签、站点和边界之间冲突的度量标准,以及衡量标签密度和对称性的度量标准。然后,Bradsheet使用多目标遗传算法生成可行解的“Pareto frontier”。它还包括关于如何突变个体的信息,以及有关提高算法速度的一些注释。

这里有很多细节,应该会给你提供一些值得思考的好食材。


谢谢,这看起来有一些有趣的想法可以开始尝试。 - Neil Coffey

10

让我们暂时忘记信息设计。这个任务让我想起了与PCB布线算法相关的一些记忆。实际上有很多共同要求,包括:

  1. 交叉口优化
  2. 大小优化
  3. 间隙优化

因此,可以将初始任务转化为类似于PCB布线的东西。

有很多信息可用,但我建议查看谭岩关于PCB布线的算法研究

它提供了大量详细信息和数十个提示。 适应当前任务 这个想法是将图像上的标记和标签视为两组引脚,并使用逃逸路由来解决任务。通常,PCB区域表示为引脚数组。同样可以对图像进行处理,可能还可以进行优化:
  1. 避免低对比度区域
  2. 避免任何文本框
  3. 等等
因此,任务可以简化为“未使用引脚的路由”。

enter image description here

最终结果可以非常接近所要求的风格:

enter image description here

谭彦的PCB布线算法研究是一个很好的继续学习的地方。

附加说明

我可以稍微改变图纸的风格,以突出相似之处。

enter image description here

做一些反向转换并保持良好的外观和可读性不应该是一个大问题。

enter image description here

无论如何,像我一样崇尚简单的人可以花费几分钟时间发明出更好或者不同的东西:

enter image description here

关于我个人而言,在这个阶段上,曲线看起来并不是一个完整的解决方案。无论如何,我只是试图展示有改进的空间,因此PCB布线方法可以被考虑作为一个选择。

8
一种选择是将其转化为整数规划问题。
假设您有n个点和分布在图表外部的n个相应标签。
可能的线路数量为n^2,如果考虑所有可能的交点,则交点数量小于n^4(如果显示了所有可能的线路)。
在我们的整数规划问题中,我们添加以下约束条件:
(决定是否打开线路(即在屏幕上显示))
  1. 对于图表上的每个点,只能打开与之连接的可能n条线路中的一条。
  2. 对于每个标签,只能打开与之连接的可能n条线路中的一条。
  3. 对于每对相交的线段line1和line2,这些线段中只能有零条或一条被打开。
  4. 可选地,我们可以最小化所有打开线路的总距离。 这增强了美感。
当同时满足所有这些约束条件时,我们就有了一个解决方案:

enter image description here

下面的代码生成了上述24个随机点的图表。
一旦您开始获得超过15个左右的点,程序的运行时间就会开始变慢。
我使用了PULP包及其默认求解器。我使用PyGame进行显示。
以下是代码:
__author__ = 'Robert'

import pygame
pygame.font.init()
import pulp
from random import randint

class Line():
    def __init__(self, p1, p2):
        self.p1 = p1
        self.p2 = p2
        self.length = (p1[0] - p2[0])**2 + (p1[1] - p2[1])**2

    def intersect(self, line2):
        #Copied some equations for wikipedia. Not sure if this is the best way to check intersection.
        x1, y1 = self.p1
        x2, y2 = self.p2
        x3, y3 = line2.p1
        x4, y4 = line2.p2
        xtop = (x1*y2-y1*x2)*(x3-x4)-(x1-x2)*(x3*y4-y3*x4)
        xbottom = (x1-x2)*(y3-y4) - (y1-y2)*(x3-x4)
        ytop = (x1*y2-y1*x2)*(y3-y4)-(y1-y2)*(x3*y4-y3*x4)
        ybottom = xbottom
        if xbottom == 0:
            #lines are parallel. Can only intersect if they are the same line. I'm not checking that however,
            #which means there could be a rare bug that occurs if more than 3 points line up.
            if self.p1 in (line2.p1, line2.p2) or self.p2 in (line2.p1, line2.p2):
                return True
            return False
        x = float(xtop) / xbottom
        y = float(ytop) / ybottom
        if min(x1, x2) <= x <= max(x1, x2) and min(x3, x4) <= x <= max(x3, x4):
            if min(y1, y2) <= y <= max(y1, y2) and min(y3, y4) <= y <= max(y3, y4):
                return True
        return False

def solver(lines):
    #returns best line matching
    lines = list(lines)
    prob = pulp.LpProblem("diagram labelling finder", pulp.LpMinimize)

    label_points = {} #a point at each label
    points = {} #points on the image
    line_variables = {}
    variable_to_line = {}

    for line in lines:
        point, label_point = line.p1, line.p2
        if label_point not in label_points:
            label_points[label_point] = []
        if point not in points:
            points[point] = []
        line_on = pulp.LpVariable("point{0}-point{1}".format(point, label_point),
            lowBound=0, upBound=1, cat=pulp.LpInteger) #variable controls if line used or not
        label_points[label_point].append(line_on)
        points[point].append(line_on)
        line_variables[line] = line_on
        variable_to_line[line_on] = line

    for lines_to_point in points.itervalues():
        prob += sum(lines_to_point) == 1 #1 label to each point..

    for lines_to_label in label_points.itervalues():
        prob += sum(lines_to_label) == 1 #1 point for each label.

    for line1 in lines:
        for line2 in lines:
            if line1 > line2 and line1.intersect(line2):
                line1_on = line_variables[line1]
                line2_on = line_variables[line2]
                prob += line1_on + line2_on  <= 1 #only switch one on.

    #minimize length of switched on lines:
    prob += sum(i.length * line_variables[i] for i in lines)

    prob.solve()
    print prob.solutionTime
    print pulp.LpStatus[prob.status] #should say "Optimal"
    print len(prob.variables())

    for line_on, line in variable_to_line.iteritems():
        if line_on.varValue > 0:
            yield line #yield the lines that are switched on

class Diagram():
    def __init__(self, num_points=20, width=700, height=800, offset=150):
        assert(num_points % 2 == 0) #if even then labels align nicer (-:
        self.background_colour = (255,255,255)
        self.width, self.height = width, height
        self.screen = pygame.display.set_mode((width, height))
        pygame.display.set_caption('Diagram Labeling')
        self.screen.fill(self.background_colour)
        self.offset = offset
        self.points = list(self.get_points(num_points))
        self.num_points = num_points
        self.font_size = min((self.height - 2 * self.offset)//num_points, self.offset//4)

    def get_points(self, n):
        for i in range(n):
            x = randint(self.offset, self.width - self.offset)
            y = randint(self.offset, self.height - self.offset)
            yield (x, y)

    def display_outline(self):
        w, h = self.width, self.height
        o = self.offset
        outline1 = [(o, o), (w - o, o), (w - o, h - o), (o, h - o)]
        pygame.draw.lines(self.screen, (0, 100, 100), True, outline1, 1)
        o = self.offset - self.offset//4
        outline2 = [(o, o), (w - o, o), (w - o, h - o), (o, h - o)]
        pygame.draw.lines(self.screen, (0, 200, 0), True, outline2, 1)

    def display_points(self, color=(100, 100, 0), radius=3):
        for point in self.points:
            pygame.draw.circle(self.screen, color, point, radius, 2)

    def get_label_heights(self):
        for i in range((self.num_points + 1)//2):
            yield self.offset + 2 * i * self.font_size

    def get_label_endpoints(self):
        for y in self.get_label_heights():
            yield (self.offset, y)
            yield (self.width - self.offset, y)

    def get_all_lines(self):
        for point in self.points:
            for end_point in self.get_label_endpoints():
                yield Line(point, end_point)


    def display_label_lines(self, lines):
        for line in lines:
            pygame.draw.line(self.screen, (255, 0, 0), line.p1, line.p2, 1)

    def display_labels(self):
        myfont = pygame.font.SysFont("Comic Sans MS", self.font_size)
        label = myfont.render("label", True, (155, 155, 155))
        for y in self.get_label_heights():
            self.screen.blit(label, (self.offset//4 - 10, y - self.font_size//2))
            pygame.draw.line(self.screen, (255, 0, 0), (self.offset -  self.offset//4, y), (self.offset, y), 1)
        for y in self.get_label_heights():
            self.screen.blit(label, (self.width - 2*self.offset//3, y - self.font_size//2))
            pygame.draw.line(self.screen, (255, 0, 0), (self.width - self.offset + self.offset//4, y), (self.width - self.offset, y), 1)

    def display(self):
        self.display_points()
        self.display_outline()
        self.display_labels()
        #self.display_label_lines(self.get_all_lines())
        self.display_label_lines(solver(self.get_all_lines()))

diagram = Diagram()
diagram.display()

pygame.display.flip()
running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

有趣的是,当然你把实际的细节委托给一个神奇的库... - Neil Coffey
1
Neil,我认为利用库是一个好主意。然而,这个库是开源的。此外,整数规划很常见。你可以在大多数语言中找到许多示例算法。这里重要的概念是约束条件的制定。现在你可以使用任何求解器。我只提供代码作为概念证明。在谷歌上搜索整数规划。 - Rusty Rob
这是一个很好的观点,我喜欢你将问题重新表述为可能的线路,并通过某些限制进行开/关。只是对于解决算法的细节,我同样感兴趣。 - Neil Coffey
1
太好了,我刚刚编辑了我的答案。现在有一张新的24个点的图片,看起来更美观了,因为我添加了一个新的“目标函数”。这个目标是最小化所有打开的线路之间的距离。 - Rusty Rob

8
我认为这个问题的实际解决方案在稍微不同的层面上。完全忽略信息设计开始解决算法问题似乎不是正确的想法。在这里找到了一个有趣的例子。

让我们确定一些重要问题:

  1. 数据最好如何查看?
  2. 它会使人们感到困惑吗?
  3. 它易读吗?
  4. 它是否真正有助于更好地理解图像?

顺便说一下,混乱确实令人困惑。我们喜欢秩序和可预测性。没有必要向初始图像引入额外的信息噪声。

enter image description here

图形信息的可读性取决于内容和呈现方式。信息的可读性涉及读者理解文本和图片风格的能力。由于额外的“嘈杂”方法,您将面临有趣的算法任务。消除混乱--寻找更好的解决方案 :)

enter image description here

请注意,这只是一个 PoC。其想法是仅使用带有清晰标记的水平线。标签位置放置简单且确定性强。可以提出几个类似的想法。

enter image description here

使用这种方法,您可以轻松平衡左右标签,避免行之间的小垂直间隙,为标签提供最佳垂直密度等。
编辑
好的,让我们看看初始过程可能是什么样子。
用户故事:作为用户,我希望重要图像被注释以简化理解并增加其解释价值。
重要假设:
1. 初始图像是用户的主要对象 2. 可读性是必须的
因此,最好的解决方案是拥有注释但不拥有它们。(我真的建议花些时间阅读创新性问题解决理论)。
基本上,对于用户来说,没有障碍可以看到初始图片,但在需要时应该有注释。这可能会稍微令人困惑,抱歉。
您认为交叉问题是以下图像背后的唯一问题吗?

enter image description here

请注意,开发方法的实际目标是提供两种信息流(图像和注释),并帮助用户尽快理解所有内容。顺便说一句,视觉记忆也非常重要。
人类视觉背后的原理:
1. 选择性注意力 2. 熟悉度检测 3. 模式检测
您想打破这些机制中的至少一个吗?我希望你不要。因为这会使实际结果不太用户友好。
那么什么可以分散我的注意力呢?
1. 图像上随机分布的奇怪线条(随机几何对象非常容易分散注意力) 2. 不统一的注释放置和样式 3. 最终合并图像和注释层产生的奇怪复杂模式
为什么应该考虑我的建议呢?
  1. 它具有简单的图案,因此模式检测将使用户停止注意注释,而看到图片
  2. 它具有统一的设计,因此熟悉度检测也会起作用
  3. 与其他解决方案相比,它不会对初始图像产生太大影响,因为线条宽度最小。
  4. 线条是水平的,不使用抗锯齿,因此它保存更多信息并提供清晰的结果
  5. 最后,它确实极大地简化了路由算法。

一些额外的评论:

  1. 不要使用随机点来测试您的算法,使用简单但重要的情况。您会发现自动化解决方案有时会失败得很惨。
  2. 我不建议直接采用我提出的方法。有许多可能的改进。
  3. 我真正建议的是向上走一级,在元级别上进行几次迭代。

分组可以用于处理Robert King提到的复杂情况:

enter image description here

或者我可以想象一下,某个点位于其默认位置的上方。但只是一瞬间,因为我不想打破主要的处理流程并影响其他标记。

enter image description here

感谢您的阅读。

我的问题确实是关于数值算法的。不过,我已经对基本的审美标准做出了决定,这些标准与你提到的相似。 - Neil Coffey
我要删除我的“答案”吗?顺便问一下,好问题。谢谢。 - Renat Gilmanov
不要误会我的意思——你的回答仍然很有价值,特别是如果你能够具体说明你提到的一些视觉限制,只是它并没有主要关注我问题的核心。 - Neil Coffey
我同意这看起来很不错,但如果有许多高度相似的点,它可能会失败,这可能是一个常见的用例。 - Rusty Rob
@robertking 感谢您的评论。有几种方法可以解决这个问题,所以我很高兴它对您也很好看 :) - Renat Gilmanov
1
@NeilCoffey 我想到了一个问题,将对角线绘制在相同的y坐标上可以大大降低相交线的概率,因此应用这种风格可以极大地简化算法。干得好! - Jacob

5
您可以找到图表的中心,然后从中心向外径向绘制点线。 如果两个点位于同一条射线上,则唯一可能出现交叉的方式是,您只需将其中一条线稍微向一个方向移动一些,将另一条线稍微向另一个方向移动一些,如下所示: Centerlines 仅显示实际部件时: All Done 如果有两个或多个点与中心共线,则可以将线稍微向一侧移动: In case of colinearity 虽然这不会产生非常好的多线段线条,但它非常清楚地标记了图表。 另外,为使其更具吸引力,最好选择一个作为对象实际中心而不仅仅是点集中心的点作为中心。

1
在顶部和底部都有标签并不是很好。原因是:占用空间,难以在某些文本块中作为图形使用等。 - Renat Gilmanov
@Renat Gilmanov,在整个图表周围加上边框至少可以解决“在某些文本块中难以使用作为图形”的问题。 - AJMansfield
它将占据很多空间,而且看起来不好(只是我的主观意见)。 - Renat Gilmanov

3
我会为您进行翻译,请查看以下内容:

我希望在您的原型中加入一件事情,也许在这之后它会被接受:

通过每个交点迭代并交换标签,重复操作直到没有交点。

该过程是有限的,因为状态数量是有限的,而每次交换都会减少所有线段长度的总和,因此不可能存在循环。


实际上,对于任何算法,我可能会通过不允许标签移动超过几个位置(从Y坐标定义的顺序)来缩小选择范围。 - Neil Coffey
你能证明这个公理吗?乍一看,我认为交换两个标签可能会引入其他的交叉。 - Rusty Rob
最后一行是一个证明,我稍微澄清了一下。 - maxim1000
不错!这是一个很好的思考方式。我猜总会有解决方案的。我想知道你如何计算时间复杂度。我猜它应该相当快吧? - Rusty Rob
嗯...状态的数量是N^N。理论上,在某些情况下,通过随机选择我们可以穿过所有这些状态。如果初始连接不是随机的,可能可以得出更好的估计... - maxim1000

1

这个问题可以看作是图形布局问题。

我建议您查看例如Graphviz库。虽然我没有进行过任何实验,但我相信通过将要标记的点和标签本身表示为节点以及引导线表示为边,您将获得良好的结果。

您需要将标签不应该出现的区域表示为“虚拟”节点,以避免重叠。

Graphvis有许多语言的绑定

即使Graphviz没有足够的灵活性来完全满足您的需求,该页面的“理论”部分也提供了能量最小化和弹簧算法的参考文献,可应用于您的问题。图形布局的文献非常丰富。


我喜欢图形可视化。我认为可以将点节点的xy位置固定下来。但是,如何告诉图形可视化连接的标签节点必须在外部某个位置呢? - Rusty Rob
就像我之前说的那样,您需要定义一个大的虚拟节点来覆盖整个图片,然后告诉它不允许重叠。我假设图表上的固定节点将被允许重叠,标签的不受约束节点将放置在外部。如果这样不起作用,实现自己的基于能量的算法将非常简单。参见 http://en.wikipedia.org/wiki/Force-based_algorithms_(graph_drawing) - Gene

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