Exploring the Multimodal Universe with Groovy™
Published: 2026-07-01 11:00AM
A recent post on X by Georgia Channing (@cgeorgiaw) caught our eye:
Seems like no one’s noticed the 80TB of astrophysics data from 30+ sources that just dropped on @huggingface … and you only need ~4GB of RAM to load it. We’re talking over 80TB of galaxy imagery taken across the spectrum, spectra of galaxies and stars, time series of variable stars, and a whole zoo of assorted measurements and physical data. And all of it can now be wrangled on your laptop, thanks to Multimodal Universe’s just released cross-matching.
The accompanying video is a lovely little terminal session: open a couple of enormous astronomical
catalogs straight from Hugging Face, cross-match them, pull out one galaxy,
and draw the galaxy and its spectrum right there in the terminal.
That last part — rendering scientific images inline in a REPL — is something Groovy 6 groovysh can do too,
via its /img command, so let’s reproduce the demo.
Full credit to Georgia Channing for the original post, and to Mike Smith (@smith42mike) who led the cross-matching work that makes the Multimodal Universe accessible on a laptop.
What is the Multimodal Universe?
The Multimodal Universe is a large, community effort (presented at NeurIPS 2024) that gathers around 100TB of astronomical data from more than 30 surveys into a single, machine-learning-friendly collection: galaxy images across multiple wavelength bands, optical spectra of galaxies and stars, time series of variable stars, and assorted catalog measurements. It is published as a family of datasets on the Hugging Face Hub.
The magic trick behind "wrangle 80TB on a 4GB laptop" is cross-matching with LSDB on top of the HATS format: the catalogs are spatially partitioned (by position on the sky using HEALPix), so joining 800k objects from SDSS against 122M objects from Gaia only ever needs to hold a few partitions in memory at a time. You never download the whole thing.
We are going to focus on the visualization half of the demo, using two of the datasets:
-
UniverseTBD/mmu_gz10— galaxy imagery derived from the Galaxy10 DECaLS set, which pairs citizen-science Galaxy Zoo morphology labels with 256×256 colour cutouts from the DESI Legacy Imaging Surveys. Each row conveniently carries a ready-made RGB PNG. -
UniverseTBD/mmu_sdss_sdss— optical spectra from the Sloan Digital Sky Survey, i.e. flux measured across wavelengths from roughly 3800 to 9200 Ångströms, plus a per-pixelmaskmarking bad samples.
We don’t even need a Python environment or the datasets locally. Hugging Face exposes a dataset viewer REST API that streams rows as JSON straight out of the underlying Parquet — so we can grab a single galaxy with an ordinary HTTP call. Requesting one row looks like this:
https://datasets-server.huggingface.co/rows?dataset=UniverseTBD/mmu_gz10&config=default&split=train&offset=0&length=1
In that JSON, the galaxy image comes back as base64-encoded PNG bytes, and the spectrum comes back as
plain arrays of lambda (wavelength), flux, and mask values. Perfect for Groovy.
Rendering images in groovysh
Groovy’s interactive shell, groovysh, has been reworked on top of
JLine 3 and gains a set of /-prefixed commands. One of them is
/img, which displays an image inline using the terminal’s graphics protocol (Sixel, Kitty, or
iTerm2 — terminals like iTerm2, WezTerm, or
Kitty all qualify). On a terminal without graphics support it
prints a summary line, and you can add --gui to pop up a Swing window instead.
/img accepts a file path, an HTTP URL, or a shell variable holding a java.awt.image.BufferedImage
(among other things), which is exactly what we need: one command for the galaxy PNG and one for a
chart we build ourselves.
The script
Here is the complete mmu.groovy script. It reads one cross-matched-style row from each dataset,
decodes the galaxy PNG, filters the masked points out of the spectrum (mirroring the video’s
s = s[~s["mask"]]), and plots the spectrum with JFreeChart, handed to /img as a
BufferedImage:
import groovy.json.JsonSlurper
import java.awt.Color
import javax.imageio.ImageIO
import org.jfree.chart.ChartFactory
import org.jfree.data.xy.XYSeries
import org.jfree.data.xy.XYSeriesCollection
rows = { String ds -> new JsonSlurper().parse(
new URL("https://datasets-server.huggingface.co/rows?dataset=$ds&config=default&split=train&offset=0&length=1")).rows[0].row }
// --- the galaxy: gz10 stores a ready-made RGB PNG as base64 bytes ---
galaxy = ImageIO.read(new ByteArrayInputStream(rows('UniverseTBD/mmu_gz10').rgb_image.bytes.decodeBase64()))
// --- the spectrum: drop masked points, like s[~s["mask"]] in the video ---
spec = rows('UniverseTBD/mmu_sdss_sdss').spectrum
pts = [spec.'lambda', spec.flux, spec.mask].transpose().findAll { !it[2] }.collect { [it[0] as double, it[1] as double] }
// --- a JFreeChart line plot -> a BufferedImage that /img renders ---
series = new XYSeries('flux')
pts.each { series.add(it[0], it[1]) }
plot = ChartFactory.createXYLineChart('SDSS spectrum', 'Wavelength (Å)', 'Flux', new XYSeriesCollection(series))
plot.XYPlot.renderer.setSeriesPaint(0, Color.MAGENTA) // magenta, to match the original demo
plot.removeLegend()
chart = plot.createBufferedImage(820, 520)
println "galaxy ${galaxy.width}x${galaxy.height}, spectrum ${pts.size()} pts -> chart ${chart.width}x${chart.height}"
A few things worth pointing out:
-
The whole data-access layer is a one-line closure.
JsonSlurperparses the HTTP response, and Groovy’s GDK gives usdecodeBase64()on the PNG string for free. -
transpose()zips the three parallel arrays (lambda,flux,mask) together so we canfindAllthe unmasked samples in one readable expression. -
The spectrum is drawn with JFreeChart:
createXYLineChartgives us axes, ticks and gridlines for free, andcreateBufferedImagehands/imgexactly theBufferedImageit wants. That is the same trick thegroovyshuser guide uses to show off XChart, Smile and Orson Charts —/imgspeaksBufferedImage, so the whole JVM charting ecosystem is one/grabaway.
Running it
Start groovysh, pull in the one charting dependency with the shell’s /grab command, load the
script, and view each result with /img:
groovy> /grab org.jfree:jfreechart:1.5.6
groovy> /load mmu.groovy
galaxy 256x256, spectrum 3713 pts -> chart 820x520
Loaded: mmu.groovy
groovy> /img $galaxy
groovy> /img $chart
We use groovysh’s `/grab command rather than an @Grab annotation in the script itself: /load
runs the file as a plain script, so grabbing the dependency in the shell first keeps that step
explicit (and matches how the groovysh guide adds XChart, Smile and the other charting libraries).
Here is the script itself, viewed through the shell’s built-in /less pager (which syntax-highlights
Groovy):

And here is the payoff — a galaxy and its spectrum, drawn directly in the terminal:

What are we looking at?
The top image is a single galaxy from the Galaxy Zoo / DECaLS set: a bright central bulge with fainter spiral structure around it, sitting in a field speckled with foreground stars and other faint sources (the little coloured dots). It is a true-colour composite assembled from the survey’s imaging bands.
The lower plot is that same class of object’s SDSS optical spectrum: flux on the vertical axis against wavelength (in Ångströms) on the horizontal axis, from about 3800Å on the left to 9200Å on the right. The characteristic shape — low and noisy in the blue, rising to a broad, fairly flat continuum toward the red — is the combined light of the galaxy’s stellar populations, with narrow spikes where emission lines and residual sky features poke through. We drew the line in magenta to match the original demo, and let JFreeChart supply the axes, ticks and gridlines. We removed the flagged (`mask`ed) pixels first, so 3713 good samples remain out of the original ~3850.
The real point: a few lines of Groovy pulled a real galaxy and a real spectrum out of an 80TB,
30-survey collection, over plain HTTP, and painted them in a terminal — with no local data download.
And because groovysh’s `/img speaks BufferedImage, reaching for a proper charting library like
JFreeChart costs one /grab and a handful of lines. And if you would rather ship no dependency at
all, the pure-JDK variant at the end of this post draws the same plot by hand.
What we didn’t reproduce
The original video’s headline feat is the cross-match itself — joining gz10 (imagery) against
sdss (spectra), or SDSS against Gaia, using LSDB’s HATS-partitioned catalogs. That machinery is
Python-native and has no direct JVM equivalent, so here we simply grabbed a representative row from
each dataset rather than computing the spatial join. Everything downstream of the match — decoding
the image, cleaning the spectrum, and rendering both — is pure Groovy and the JDK.
A dependency-free variant
Reaching for JFreeChart is the pragmatic choice, but the demo also works with no external dependency
at all — the JDK’s own Java2D can paint the line plot straight into a BufferedImage, which is
exactly what /img consumes. It is more code, but it ships with the JVM, so there is no /grab: just
/load mmu_java2d.groovy.
import groovy.json.JsonSlurper
import javax.imageio.ImageIO
import module java.desktop
rows = { String ds -> new JsonSlurper().parse(
new URL("https://datasets-server.huggingface.co/rows?dataset=$ds&config=default&split=train&offset=0&length=1")).rows[0].row }
// --- the galaxy: gz10 stores a ready-made RGB PNG as base64 bytes ---
galaxy = ImageIO.read(new ByteArrayInputStream(rows('UniverseTBD/mmu_gz10').rgb_image.bytes.decodeBase64()))
// --- the spectrum: drop masked points, like s[~s["mask"]] in the video ---
spec = rows('UniverseTBD/mmu_sdss_sdss').spectrum
pts = [spec.'lambda', spec.flux, spec.mask].transpose().findAll { !it[2] }.collect { [it[0] as double, it[1] as double] }
// --- a tiny Java2D line plot -> a BufferedImage that /img renders ---
spectrumPlot = { data ->
int w = 820, h = 520
def img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB)
def g = img.createGraphics()
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g.color = Color.WHITE; g.fillRect(0, 0, w, h)
int ml = 70, mr = 20, mt = 20, mb = 44, pw = w - ml - mr, ph = h - mt - mb
def xs = data*.get(0), ys = data*.get(1)
double xmin = xs.min(), xmax = xs.max(), ymin = ys.min(), ymax = ys.max()
def sx = { double x -> ml + (x - xmin) / (xmax - xmin) * pw }
def sy = { double y -> mt + (1 - (y - ymin) / (ymax - ymin)) * ph }
g.color = Color.BLACK
g.drawLine(ml, mt, ml, mt + ph); g.drawLine(ml, mt + ph, ml + pw, mt + ph)
g.font = g.font.deriveFont(11f)
(0..4).each { i ->
double xv = xmin + (xmax - xmin) * i / 4; int px = sx(xv) as int
g.drawLine(px, mt + ph, px, mt + ph + 4); g.drawString(sprintf('%.1f', xv), px - 20, mt + ph + 20)
double yv = ymin + (ymax - ymin) * i / 4; int py = sy(yv) as int
g.drawLine(ml - 4, py, ml, py); g.drawString(sprintf('%.1f', yv), 8, py + 4)
}
g.color = Color.MAGENTA; g.stroke = new BasicStroke(1.1f)
def path = new Path2D.Double()
data.eachWithIndex { p, i -> i == 0 ? path.moveTo(sx(p[0]), sy(p[1])) : path.lineTo(sx(p[0]), sy(p[1])) }
g.draw(path); g.dispose(); img
}
chart = spectrumPlot(pts)
println "galaxy ${galaxy.width}x${galaxy.height}, spectrum ${pts.size()} pts -> chart ${chart.width}x${chart.height}"
Two things worth noting:
-
No
/grabthis time — the plot is hand-drawn with Java2D in thespectrumPlotclosure, so we scale the axes, draw the ticks, and stroke the line ourselves instead of getting them for free. That is the cost of dropping the dependency. -
The
import module java.desktopon line 3 is a JDK module import (JEP 494), supported in Groovy 6: it pulls in every package thejava.desktopmodule exports (java.awt,java.awt.geom,java.awt.image, and more) in a single line — handy here because the hand-rolled plot reaches into several AWT corners (BufferedImage,Path2D,BasicStroke,RenderingHints,Color).
More information
-
The original post: Georgia Channing on X
-
The Multimodal Universe project and its GitHub repository
-
Datasets on Hugging Face: mmu_gz10 and mmu_sdss_sdss
Conclusion
We used a viral astronomy demo as an excuse to show off groovysh’s `/img command, JDK module
imports in Groovy 6, and just how little code it takes to reach into a massive scientific dataset and
visualize it. Go discover some galaxies!
