DR McCullochHOMESHOPABOUT

CSV TO LASER

Temple of
the Abundance

CSV prompts → Stable Diffusion → image processing → vector tracing → LightBurn laser files.

the description field, pyproject.toml · version 0.1.0 · requires-python >=3.11

I wrote this to take a spreadsheet of prompts and hand back files a laser cutter will run. The chain runs again here, in your browser, on the repository’s own CSV fixtures, its own config defaults, its own error-diffusion kernel and its own element order, and what comes off the end of it is a project.lbrn2 written the waylbrn2.py writes one. Where a stage genuinely cannot run in a browser I say so beside it rather than quietly faking it.

TO THE BENCH

00 · SOURCE

Five stages and seven folders

The package is 57 Python modules and 10,255 lines with 91 tests under them, and the laser half of it went in during a single day. Twenty-three of the forty-two commits land on 4 April 2026, the initial commit first and, before the day was out, both “Rewrite LBRN2 builder to match real LightBurn format” and “Match engrave settings to real xTool D1 Pro LightBurn config”. That was the day I stopped guessing at the file format and started reading real ones.

A batch is one CSV file: each row becomes a JobSpec carrying 55 fields and each job gets a folder of its own where the stages drop their artefacts in order. Celery moves the work between four named queues so the GPU stage and the CPU stages can sit on different machines. The four tasks that post to Stable Diffusion are the only ones that ever retry, backing off by2 ** self.request.retries * 10 seconds: ten, then twenty, then forty. Imaging, vectorize and lightburn each declare a ceiling of two and then never callself.retry, so those retries sit there unspent.

README.md lines 7–11 · the five stages as written, split into columns
  1. 1IngestParse CSV of prompts into job specs
  2. 2DiffusionGenerate images via Easy Diffusion API (port 9000)
  3. 3ImagingInvert, grayscale, contrast boost, Jarvis dithering
  4. 4VectorizePotrace bitmap-to-SVG with outline separation
  5. 5LightBurn`.lbrn2` + layered SVG (Jarvis engrave @ 50 lines/cm + cut outlines)
STAGE_DIRS · src/temple/core/paths.py lines 9–17, artefact names from its path helpers (33, 37, 46, 50, 99, 110) and from body3d/overlay.py line 622
folderstage keyartefact
01_rawdiffusionrender.png
01_body3dbody3dperson_N_cutout.png
02_processedimagingprocessed.png
03_vectorvectorizetraced.svg
04_lightburnlightburnproject.lbrn2
05_tile_upscaletile_upscaleupscaled.png
06_tiled_diffusiontiled_diffusionmosaic.png
task_routes · src/temple/pipeline/celery_app.py lines 29–39
queuetasksmax_retries
diffusiontask_diffusion Β· task_refine Β· task_tile_upscale Β· task_tiled_diffusion Β· task_batch_body3d3 Β· 2 Β· 1 Β· 1 Β· 0
imagingtask_imaging2, never spent
vectorizetask_vectorize2, never spent
lightburntask_seed_reuse Β· task_lightburn1 and 2, never spent

On Windows the config forces worker_pool = "solo" and a concurrency of one, with the reason written above it: Celery’s prefork pool doesn’t work on Windows (billiard needs fork()).

01 · THE BENCH

One row, all the way down

Pick a row out of the example CSV the repository ships or the fixture its tests run on, drop a plate into 01_raw and turn the two dials. Every pane recomputes from the numbers the Python would use: the reader’s fallbacks, the validator’s rules, the four Pillow filters in the order process_image calls them, the binarise-and-walk that becomes the cut layer and the element order in lbrn2.py. Stable Diffusion and potrace are the two things a browser cannot honestly run, and each of them is labelled where it stands.

THE ROW

THE PLATE12 of the 400 tiles the repository cut for itself

THE DIALSthe two imaging columns the CSV exposes
01_rawrender.png

This is a tile the repository cut out of its own scan, standing in for a render. Nothing diffuses here, so the pane below holds the requesta1111_client.render would build and post instead, key for key. The one part it cannot do is the lookup: the real client asks the running WebUI for the exact checkpoint and VAE titles before it fills override_settings in, and drops the VAE key when the file behind it has gone, so what you get here are the two names as the CSV gives them.

POST /sdapi/v1/txt2imgthe body, less the lookups
02_processedprocessed.png

the four calls, in order
img = invert(img)
img = desaturate(img)
img = auto_levels(img)

contrast = spec.contrast_factor or settings.imaging_contrast_factor
img = boost_contrast(img, factor=contrast)

src/temple/imaging/processor.py lines 80–85 · verbatim, less the indent

03_vectortraced.svg

the longest path’s d
04_lightburnproject.lbrn2

Written in the element order build_lbrn2 writes it, down to lxml’s single-quoted declaration and its two-space indent, with every layer value read out of config/defaults.toml rather than my machine’s .env.local, which moves the engrave minimum from 60 to 50. I checked the names, the order, the attributes and the text-content formats against the builder. I have not opened one of these in LightBurn, so that is as far as the claim goes.

02 · THE KERNEL

Forty-eight parts of an error

A laser fires or it doesn’t, so engraving a photograph means turning continuous grey into a field of dots, and the way you keep the grey is to take the error you make at each pixel and push it into the pixels you have not reached yet. Jarvis, Judice and Ninke spread that error over twelve neighbours across three rows, weighted out of forty-eight, which is a much wider net than Floyd and Steinberg’s four.

The loop below is the one in the repository: threshold at a flat 128, error pushed tox + kx - 2 because the kernel’s centre sits at column two, left to right and top to bottom with no serpentine. Here it runs at the canvas’s own resolution. The pipeline resizes to 254 DPI before it saves, which the comment reads as ten dots per millimetre and justifies by the cost of dithering, so a job asking for a 250 mm square becomes 2,500 by 2,500 pixels and this loop would take all 6.25 million of them one at a time.

Nothing in the shipped pipeline calls this any more. Commit 39c10ff, on the same day as everything else, is titled “Remove dithering step — LightBurn handles Jarvis dithering”, and the imaging docstring was rewritten to match. Two tests are the only things holding the module in the tree.

src/temple/imaging/dither.py lines 8–23 · verbatim, comment and all

Hover a cell for where that share of the error lands.

THE DOTSmove the pointer over the plate to lift out a twenty-two pixel square

src/temple/imaging/dither.py lines 34–55 · verbatim

03 · THE FILE

What LightBurn wanted

My first builder wrote the XML anyone would write from the outside: anImage type on a CutSetting, an integer for the dither mode, a dpiValue for the line spacing, the geometry parked inValue attributes. Every one of those guesses got thrown out the same day I opened real 2.x project files and copied what they actually do, and the list of differences ended up at the top of the module as its docstring.

The result is a file with two layers in it. Layer zero is aCutSetting_Img named Engrave carrying the speed, three powers, the scan option, the line interval in millimetres, the angle and the word jarvisas a string, and layer one is a plain CutSetting named Cut. Then comes the bitmap shape with the whole processed image base64’d into aData attribute, and after it one Shape Type="Path" per outline, each with its points run together as V{x} {y}and its segments as L{i} {j} closing back to zero.

If a row gives no size in millimetres the builder falls back to25.4 / 96.0 per pixel, so the physical size of the thing you cut is whatever the pixel count happens to be at 96 DPI. On the bench that fallback is what the one row of prompts.example.csv that parses gets, and you can watch the millimetres in the header move when you switch to a fixture row that names 100 by 100.

src/temple/lightburn/lbrn2.py lines 1–12 · verbatim
src/temple/lightburn/lbrn2.py lines 123–139 · verbatim
src/temple/vectorize/svg.py lines 20–28 · verbatim

Every path is written to two decimal places behind an M, then anL for each point after the first, then a Z, and the group they all go into is stroke="red", fill="none", stroke_width=0.5, which is where the red on the bench comes from.

04 · NOT CALLED

What it doesn’t do

A pipeline built this fast leaves things behind: a module that stopped being called and kept its tests, four LoRA slots that are read and validated and dropped, a config file with nothing on the other end of it. Every line below was checked with a grep across all 94 tracked .py files before it went on this page, counting the package, its tests, its scripts and the run_mosaic.py driver at the root. Open one to see what it rests on.

COLOPHON

Everything on the bench that could honestly run in a browser does. The four Pillow filters are reimplemented against Pillow’s own arithmetic, including the 601-2 luma weights convert("L") uses, the one-percent histogram clip inautocontrast and the blend against a flat plane at the image’s own rounded mean that ImageEnhance.Contrast performs. The error diffusion is the repository’s kernel run in the repository’s loop order, and the XML is assembled element by element in the builder’s.

Two things stand in: there is no diffusion model here, so a tile from the repository’s own grid takes the render’s place and the pane beside it holds the request body the client would post, less the two names it would look up on the running WebUI first. There is no potrace here either, so the cut layer comes from a plain boundary walk over the binarised image, thinned with Ramer-Douglas-Peucker, which is my substitution and appears nowhere in the repository. The binarisation rule, the speckle threshold of two it hands potrace and the two-decimalM/L/Z output are exactly the repository’s, but there is no corner detection and no curve optimisation behind them, so my path count will not match the num_paths a real run logs.

The plates are twelve of the four hundred tiles in cut_image/, whichscripts/cut_image_grid.py cut twenty columns by twenty rows from a 233,682,756-byte scan of Hieronymus Bosch’s The Garden of Earthly Delights measuring 30,000 by 17,078 pixels, so each tile is 1,500 pixels wide. Bosch died in 1516 and the painting is public domain. I curated the twelve for tonal range and resized each to 700 pixels on the long edge.

The source drive is mounted read-only over SMB and nothing was written to it. Theoutput/ directory is there but cannot be listed on that share, so no pipeline artefact was available to compare against and none is claimed here. The prompts you can pick are the ones from prompts.example.csv andtests/fixtures/sample_prompts.csv, the example file and the test fixture. The batches I actually ran, in prompts.csv and test_run.csv, are not mandalas and oak leaves, and their numbers are the only part of them on this page: 1024 by 1024, 40 to 50 steps, guidance 7 to 12, sampler DPM++_2M_SDE_Heun, seed 1231231, 250 mm square and the whole body3d chain switched on.

the whole history

    Temple of the Abundance · temple-of-the-abundance 0.1.0 · DR McCulloch

    ← back home