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 BENCH00 · 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.
- 1IngestParse CSV of prompts into job specs
- 2DiffusionGenerate images via Easy Diffusion API (port 9000)
- 3ImagingInvert, grayscale, contrast boost, Jarvis dithering
- 4VectorizePotrace bitmap-to-SVG with outline separation
- 5LightBurn`.lbrn2` + layered SVG (Jarvis engrave @ 50 lines/cm + cut outlines)
| folder | stage key | artefact |
|---|---|---|
| 01_raw | diffusion | render.png |
| 01_body3d | body3d | person_N_cutout.png |
| 02_processed | imaging | processed.png |
| 03_vector | vectorize | traced.svg |
| 04_lightburn | lightburn | project.lbrn2 |
| 05_tile_upscale | tile_upscale | upscaled.png |
| 06_tiled_diffusion | tiled_diffusion | mosaic.png |
| queue | tasks | max_retries |
|---|---|---|
| diffusion | task_diffusion Β· task_refine Β· task_tile_upscale Β· task_tiled_diffusion Β· task_batch_body3d | 3 Β· 2 Β· 1 Β· 1 Β· 0 |
| imaging | task_imaging | 2, never spent |
| vectorize | task_vectorize | 2, never spent |
| lightburn | task_seed_reuse Β· task_lightburn | 1 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.
This row never leaves the ingest stage, so the three panes below are running on the dials alone. Drop the stray cell and a real job goes through them.
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.
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
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.
Hover a cell for where that share of the error lands.
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.
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.
Rows one and two of prompts.example.csv carry an extra empty cell after negative_prompt, so every value after it lands one column to the right. The vae column receives sd-v1-4 and the width column receives vae-ft-mse-840000-ema-pruned, which int() will not take, while the third row is aligned and parses cleanly. You can watch this happen on the bench by picking either of the first two rows.
evidence
ValueError: invalid literal for int() with base 10: 'vae-ft-mse-840000-ema-pruned'A grep for jarvis_dither across all 94 tracked .py files returns its definition, its two tests and nothing else. The imaging stage stopped calling it in commit 39c10ff on 4 April 2026, the one that took the dithering step out on the grounds that LightBurn does it at engrave time, and the docstring was rewritten to say so. The module is still there, still correct and still tested.
evidence
src/temple/imaging/dither.py:26 Β· tests/test_imaging/test_dither.py:8,17,28engrave_dpi is round(25.4 / interval), and the shipped interval is 0.5 mm, which gives 51 DPI, or 20 lines per centimetre. Fifty lines per centimetre needs a 0.2 mm interval and comes out as 127 DPI, which survives only in cut_settings.py, where engrave_preset works the arithmetic out longhand in a comment and hands the result to a factory nothing calls.
evidence
config/defaults.toml:38 engrave_interval = 0.5 β dpi Value="51"lbrn2.py opens with "from temple.lightburn.cut_settings import cut_preset, engrave_preset" and then writes every element out of TempleSettings fields instead. The dataclass behind those factories carries a dither_mode of 4 with the comment "4 = Jarvis", which is the integer form the module docstring says LightBurn does not want.
evidence
src/temple/lightburn/lbrn2.py:32 (import) Β· no call site in any tracked .pyThe reader loops from 1 to 5 pulling lora_N_name and lora_N_weight, the validator checks every one of them against the catalogue and job_details.txt prints them all. The request builder then takes spec.loras[0] and the client appends exactly one tag to the prompt. Slots two to five are read, checked, written down and dropped.
evidence
reader.py:31-39 β render.py:144-146 β a1111_client.py:360-364The CSV column is parsed into JobSpec and printed into job_details.txt next to every render. config/laser_profiles/generic_co2.toml sits in the tree with an engrave block and a cut block in it. Grepping for the filename, for the directory and for any TOML load outside config.py returns nothing.
evidence
models.py:117,243,247 Β· reader.py:182 Β· tomllib is imported once, in config.py:5.env.local opens with TEMPLE_LASER_LINES_PER_CM=10. TempleSettings has no laser_lines_per_cm and is configured with extra="ignore", so pydantic reads that line and discards it. The same file quietly sets the engrave minimum power to 50 where defaults.toml says 60, and that one does take.
evidence
src/temple/core/config.py:38 extra="ignore"save_penis_crops runs NudeNet over every raw render, keeps the detections labelled MALE_GENITALIA_EXPOSED that score 0.4 or better and writes each one out as its own PNG beside the render, after which the pipeline carries straight on with the untouched original. Nothing is masked, blurred or rejected, so what the module actually is, whatever its name says, is a cropper.
evidence
src/temple/imaging/processor.py:75 save_penis_crops(raw_path)config/defaults.toml points AUTOMATIC1111 at 127.0.0.1:7860, while .env.example ships TEMPLE_SD_BASE_URL=http://localhost:9000, which is the Easy Diffusion port the README names, and docker-compose sends the GPU worker to host.docker.internal:9000. The env file wins over the TOML on any machine that copies it, so the documented default and the working default are two different numbers.
evidence
defaults.toml:7 Β· .env.example:16 Β· docker/docker-compose.yml:25trace_image asks for opticurve and an alphamax of 1.0, so potrace does the full job of fitting BΓ©zier curves to the bitmap. get_outline_paths then keeps every corner segment as it stands and, for each curved one, evaluates it at t = 0.25, 0.5, 0.75 and 1.0 and keeps only those four points. The SVG and the .lbrn2 both come out as chains of straight lines, which the path builder admits in a comment.
evidence
lbrn2.py:123 # VertList: "Vx1 y1Vx2 y2..." format (line-only, no bezier control points)The bitmap shape opens processed.png, re-encodes it to PNG in memory and base64s it straight into the Data attribute. The comment above the call says LightBurn applies Jarvis dithering, and that is the whole reason the repositoryβs own dither module went quiet.
evidence
lbrn2.py:262 # Engrave layer: embedded grayscale image (LightBurn applies Jarvis dithering)
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.
Temple of the Abundance · temple-of-the-abundance 0.1.0 · DR McCulloch
← back home