Fruity Eclipse Collections

作者: Paul King

发布时间:2022-10-13 11:05AM


这篇博文延续了上一篇文章中使用水果 emoji 的主题,但我们不关注深度学习,而是先探索一些 Eclipse Collections 的顶级方法,并使用水果 emoji 示例进行聚类分析(使用 K-Means 算法)。

Eclipse Collections 水果沙拉

首先,我们将定义一个 Fruit 枚举(与相关的 Eclipse Collections kata 相比,它增加了一种水果)

enum Fruit {
    APPLE('🍎', Color.RED),
    PEACH('🍑', Color.ORANGE),
    BANANA('🍌', Color.YELLOW),
    CHERRY('🍒', Color.RED),
    ORANGE('🍊', Color.ORANGE),
    GRAPE('🍇', Color.MAGENTA)

    public static ImmutableList<Fruit> ALL = Lists.immutable.with(values())
    public static ImmutableList<String> ALL_EMOJI = Lists.immutable.with(*values()*.emoji)
    final String emoji
    final Color color

    Fruit(String emoji, Color color) {
        this.emoji = emoji
        this.color = color
    }

    static Fruit of(String emoji) {
        values().find{it.emoji == emoji }
    }
}

我们可以在以下示例中使用此枚举,这些示例展示了许多常见的 Eclipse Collections 方法

assert Lists.mutable.with('🍎', '🍎', '🍌', '🍌').distinct() ==
        Lists.mutable.with('🍎', '🍌')

var onlyBanana = Sets.immutable.with('🍌')

assert Fruit.ALL_EMOJI.select(onlyBanana::contains) == List.of('🍌')

assert Fruit.ALL_EMOJI.reject(onlyBanana::contains) ==
        List.of('🍎', '🍑', '🍒', '🍊', '🍇')

assert Fruit.ALL.groupBy(Fruit::getColor) ==
        Multimaps.mutable.list.empty()
                .withKeyMultiValues(RED, Fruit.of('🍎'), Fruit.of('🍒'))
                .withKeyMultiValues(YELLOW, Fruit.of('🍌'))
                .withKeyMultiValues(ORANGE, Fruit.of('🍑'), Fruit.of('🍊'))
                .withKeyMultiValues(MAGENTA, Fruit.of('🍇'))

assert Fruit.ALL.countBy(Fruit::getColor) ==
        Bags.immutable.withOccurrences(RED, 2, YELLOW, 1, ORANGE, 2, MAGENTA, 1)

Fruit.ALL_EMOJI.chunk(4).with {
    assert first == Lists.mutable.with('🍎', '🍑', '🍌', '🍒')
    assert last == Lists.mutable.with('🍊', '🍇')
}

// For normal threads, replace 'withExistingPool' line of code with:
//GParsExecutorsPool.withPool { pool ->
GParsExecutorsPool.withExistingPool(Executors.newVirtualThreadPerTaskExecutor()) { pool ->
    var parallelFruit = Fruit.ALL.asParallel(pool, 1)
    var redFruit = parallelFruit.select(fruit -> fruit.color == RED).toList()
    assert redFruit == Lists.mutable.with(Fruit.of('🍎'), Fruit.of('🍒'))
}

最后一个示例在并行线程中计算红色水果。根据代码,当在启用了预览功能的 JDK19 上运行时,它使用虚拟线程。您可以按照注释中的建议在其他 JDK 版本或使用普通线程运行。除了 Eclipse Collections,我们的类路径中还有 GPars 库。这里我们只使用一个方法,它为我们管理线程池生命周期。

探索 emoji 颜色

为了好玩,让我们看看每种水果的指定颜色是否与相关 emoji 的颜色匹配。与之前的博客一样,我们将使用稍好一点的 Noto Color Emoji 字体来显示我们的水果,如下所示

Noto Color Emoji

我们将使用 Eclipse Collections 的 BiMap 在颜色名称和 java.awt 颜色之间来回切换

@Field public static COLOR_OF = BiMaps.immutable.ofAll([
    WHITE: WHITE, RED: RED, GREEN: GREEN, BLUE: BLUE,
    ORANGE: ORANGE, YELLOW: YELLOW, MAGENTA: MAGENTA
])
@Field public static NAME_OF = COLOR_OF.inverse()

我们还将使用一些辅助函数在 RGB 和 HSB 颜色值之间切换

static hsb(int r, int g, int b) {
    float[] hsb = new float[3]
    RGBtoHSB(r, g, b, hsb)
    hsb
}

static rgb(BufferedImage image, int x, int y) {
    int rgb = image.getRGB(x, y)
    int r = (rgb >> 16) & 0xFF
    int g = (rgb >> 8) & 0xFF
    int b = rgb & 0xFF
    [r, g, b]
}

HSB 颜色空间表示 0 到 360 度范围内的颜色光谱

图片来源:https://nycdoe-cs4all.github.io/units/1/lessons/lesson_3.2

Color Circle

我们有两个辅助方法来处理颜色。第一个挑选出“主要是黑色”和“主要是白色”的颜色,而第二个使用 switch 表达式为我们感兴趣的颜色划分出颜色空间的某些区域

static range(float[] hsb) {
    if (hsb[1] < 0.1 && hsb[2] > 0.9) return [0, WHITE]
    if (hsb[2] < 0.1) return [0, BLACK]
    int deg = (hsb[0] * 360).round()
    return [deg, range(deg)]
}

static range(int deg) {
    switch (deg) {
        case 0..<16 -> RED
        case 16..<35 -> ORANGE
        case 35..<75 -> YELLOW
        case 75..<160 -> GREEN
        case 160..<250 -> BLUE
        case 250..<330 -> MAGENTA
        default -> RED
    }
}

请注意,JDK 没有标准的 PURPLE 颜色,因此我们通过为 MAGENTA 选择合适的广谱来将紫色与洋红色组合。

我们使用 Plotly 3D 交互式散点图(由 Tablesaw Java 数据框和可视化库支持)来可视化我们的 emoji 颜色(作为颜色光谱上的度数)与 XY 坐标:Color vs xy plot

我们将尝试 3 种方法来确定每个 emoji 的主要颜色

  • 最常见的颜色:我们找到每个点的颜色光谱值并计算每种颜色的点数。将选择点数最多的颜色。这很简单,在许多情况下都有效,但如果一个苹果或樱桃有 100 种红色深浅,但只有一种绿色深浅用于茎或叶,则可能会选择绿色。

  • 最常见的范围:我们将每个点分组到颜色范围中。将选择点数最多的范围。

  • 最大簇的质心:我们将 emoji 图像分成一个子图像网格。我们将对子图像中每个点的 RGB 值执行 K-Means 聚类。这将把颜色相似的点聚集到一个簇中。将选择点数最多的簇,并将其质心选为选定的主要颜色。这种方法会影响按颜色对子图像进行像素化。这种方法受到这篇Python 文章的启发。

最常见的颜色

忽略背景白色,我们 PEACH emoji 最常见的颜色是橙色。下图显示了每种颜色的计数:PEACH 的颜色直方图

最常见的范围

如果不是计算每种颜色,而是将颜色分组到它们的范围中并计算每个范围中的数字,我们得到 PEACH 的以下图表:PEACH 的范围直方图

K-Means

K-Means 是一种用于查找簇质心的算法。对于 k=3,我们将从选择 3 个随机点作为起始质心开始。

kmeans step 1

我们将所有点分配到它们最近的质心

kmeans step 2

根据此分配,我们从其所有点重新计算每个质心

kmeans step 3

我们重复此过程,直到找到稳定的质心选择,或者我们已达到一定的迭代次数。我们使用了 Apache Commons Math 中的 K-Means 算法。

这是在 PEACH emoji 的完整点集上运行时我们期望得到的结果。黑点是质心。它找到了一个绿色、一个橙色和一个红色质心。分配到其上的点数最多的质心应该是最主要的颜色。(这是另一个交互式 3D 散点图。)

RgbPeach3d 我们可以将分配给每个簇的点数绘制成条形图。(我们使用了一个 Scala 绘图库来展示 Groovy 与 Scala 的集成。)桃色质心大小

绘制上述图表的代码如下所示

var trace = new Bar(intSeq([1, 2, 3]), intSeq(sizes))
        .withMarker(new Marker().withColor(oneOrSeq(colors)))

var traces = asScala([trace]).toSeq()

var layout = new Layout()
        .withTitle("Centroid sizes for $fruit")
        .withShowlegend(false)
        .withHeight(600)
        .withWidth(800)

Plotly.plot(path, traces, layout, defaultConfig, false, false, true)

带有子图像的 K-Means

我们为第三种选择采用的方法增强了 K-Means。与刚刚所示的图表对整个图像查找质心不同,我们将图像分成子图像,并对每个子图像执行 K-Means。我们的整体主要颜色被确定为我们所有子图像中最常见的颜色。

整合所有内容

这是涵盖所有三种方法的最终代码(包括打印一些漂亮的图像,突出显示第三种方法和 Plotly 3D 散点图)

var results = Fruit.ALL.collect { fruit ->
    var file = getClass().classLoader.getResource("${fruit.name()}.png").file as File
    var image = ImageIO.read(file)

    var colors = [:].withDefault { 0 }
    var ranges = [:].withDefault { 0 }
    for (x in 0..<image.width) {
        for (y in 0..<image.height) {
            def (int r, int g, int b) = rgb(image, x, y)
            float[] hsb = hsb(r, g, b)
            def (deg, range) = range(hsb)
            if (range != WHITE) { // ignore white background
                ranges[range]++
                colors[deg]++
            }
        }
    }
    var maxRange = ranges.max { e -> e.value }.key
    var maxColor = range(colors.max { e -> e.value }.key)

    int cols = 8, rows = 8
    int grid = 5 // thickness of black "grid" between subimages
    int stepX = image.width / cols
    int stepY = image.height / rows
    var splitImage = new BufferedImage(image.width + (cols - 1) * grid, image.height + (rows - 1) * grid, image.type)
    var g2a = splitImage.createGraphics()
    var pixelated = new BufferedImage(image.width + (cols - 1) * grid, image.height + (rows - 1) * grid, image.type)
    var g2b = pixelated.createGraphics()

    ranges = [:].withDefault { 0 }
    for (i in 0..<rows) {
        for (j in 0..<cols) {
            def clusterer = new KMeansPlusPlusClusterer(5, 100)
            List<DoublePoint> data = []
            for (x in 0..<stepX) {
                for (y in 0..<stepY) {
                    def (int r, int g, int b) = rgb(image, stepX * j + x, stepY * i + y)
                    var dp = new DoublePoint([r, g, b] as int[])
                    var hsb = hsb(r, g, b)
                    def (deg, col) = range(hsb)
                    data << dp
                }
            }
            var centroids = clusterer.cluster(data)
            var biggestCluster = centroids.max { ctrd -> ctrd.points.size() }
            var ctr = biggestCluster.center.point*.intValue()
            var hsb = hsb(*ctr)
            def (_, range) = range(hsb)
            if (range != WHITE) ranges[range]++
            g2a.drawImage(image, (stepX + grid) * j, (stepY + grid) * i, stepX * (j + 1) + grid * j, stepY * (i + 1) + grid * i,
                    stepX * j, stepY * i, stepX * (j + 1), stepY * (i + 1), null)
            g2b.color = new Color(*ctr)
            g2b.fillRect((stepX + grid) * j, (stepY + grid) * i, stepX, stepY)
        }
    }
    g2a.dispose()
    g2b.dispose()

    var swing = new SwingBuilder()
    var maxCentroid = ranges.max { e -> e.value }.key
    swing.edt {
        frame(title: 'Original vs Subimages vs K-Means',
                defaultCloseOperation: DISPOSE_ON_CLOSE, pack: true, show: true) {
            flowLayout()
            label(icon: imageIcon(image))
            label(icon: imageIcon(splitImage))
            label(icon: imageIcon(pixelated))
        }
    }

    [fruit, maxRange, maxColor, maxCentroid]
}

println "Fruit  Expected      By max color  By max range  By k-means"
results.each { fruit, maxRange, maxColor, maxCentroid ->
    def colors = [fruit.color, maxColor, maxRange, maxCentroid].collect {
        NAME_OF[it].padRight(14)
    }.join().trim()
    println "${fruit.emoji.padRight(6)} $colors"
}

这是结果图像

peach images banana images cherry images orange images grape images apple images

这是最终结果

results

在我们的例子中,所有三种方法都产生了相同的结果。其他 emoji 的结果可能会有所不同。