Field journal Entry 06

From SDXL to NumPy: Making Habitagram's Journey Art Deterministic

6 min read
Habitagram Generative Media Python NumPy React Native Stable Diffusion

Habitagram's journey climbs six biomes from sea to summit, and the first version of that art came out of Stable Diffusion XL. It got surprisingly far and taught me why diffusion was the wrong tool, so I rebuilt the backgrounds as a deterministic numpy engine. Why SDXL lost, how the paper-cut look is built from formulas, and the seam model it took a phone to find.

Habitagram is a habit tracker I am building for a December 2026 release. Your streaks carry a wanderer up through six biomes: sea, desert, steppe, forest, volcano, mountain. The journey screen stacks them into one tall climb, six slabs of 1080x2400 pixels forming a 14,400-pixel canvas from navy sea floor to snow.

The SDXL version ran locally, with IP-Adapter feeding a reference image into the model so all six biomes would share one style.

Where SDXL lost

Three compounding problems. First, style anchoring bled more than style: at full anchor strength, a desert-crop reference turned the sea biome into ochre dunes, palette and all. Aiming the adapter at the style attention blocks alone, the layers that are supposed to carry look without palette, did not strip color the way the technique promises. Tunable, but every biome became a negotiation.

Second, no reproducibility. Every prompt tweak rerolled the whole image, so “make the shore slightly warmer” meant a new image with a hundred other uninvited changes.

Third: these six backgrounds must tile into one continuous climb. Each biome’s bottom edge has to meet its neighbor’s top edge exactly. A diffusion model cannot promise an edge.

So SDXL got demoted to what it is good at, one-off hero sprites, and I rebuilt the backgrounds as a deterministic engine: journey_biome.py, just over 700 lines of numpy and PIL that draw every slab from formulas and one seed.

A paper-cut style, from formulas

The look is layered paper: flat color bands with torn edges, like cut construction paper. Each biome is a stack of bands with one of three edge types: torn (a wavy line plus smoothed noise for the deckle), hard (a crisp cut for cross-hue boundaries where any gradient would bleed), or a Catmull-Rom curve through control points for shaped coastlines, the sea shore among them, which later got its own look-dev pass in a browser: the prototype in the browser entry.

The three edge kinds drawn by the engine at seed 42, one per panel. Torn: a wave of 44 px amplitude with noise of sigma 7 smoothed by a 9 px average. Hard: 28 px amplitude, noise sigma 1.2, a 5 px average, flat fills on both sides. Catmull: a curve through 3 control points and no random numbers, the sea shore.

What sells the paper is the grain. Two orthogonal passes of smoothed white noise, vertical fibers and horizontal fibers, blended half and half into a weave, then high-passed so only the ~4px tooth remains, plus a soft large-scale mottle for uneven lighting. Applied at 16% strength it reads as canvas texture instead of pixel noise.

One bug got past the design. Per-biome random-number streams were keyed with Python’s built-in hash(), which is salted per process. Every run rendered slightly different art while the code claimed seed 42, invisible until I diffed two renders byte for byte. The fix is hashlib.sha256 for any seed derivation that must reproduce. A test now pins every biome’s SHA-256 at seed 42 in test_refs/journey_biome_ref.json, so this class of bug cannot return silently; that harness is the golden fingerprints entry.

Two code cards. Before: key = hash(...), salted per process through PYTHONHASHSEED, new bytes every run. After: the _rng function in journey_biome.py takes hashlib.sha256 of the tier and primitive name, keeps the first 4 bytes modulo 2 to the 31, and feeds seed, tier and key into np.random.default_rng, the same bytes in every process.

The seam model a montage hid and a phone exposed

The seams between biomes went through a full redesign, and the bug that forced it never appeared on my monitor.

The first model gave every slab transitions at both its top and bottom, each ramping to a shared junction color. In my review montage, all five seams side by side and compressed 4:1, it looked clean. On the phone (an Android dev build) at full scale, every boundary showed two torn edges and a wedge of junction color between them: stacked cards, not one continuous world.

The redesign gives each seam a single owner. The lower biome carries the one torn edge at its top and is painted above the tear in the upper biome’s own color; the upper biome ends flat in that same color. One edge per boundary, and the slab boundary itself becomes invisible. Ten pixels of flat color at each slab end are the insurance against a crop landing a pixel off.

Before and after the seam redesign. Before: the upper and lower slab each tear into a shared junction color, two torn edges with a wedge between them. After: the upper slab ends flat, the lower slab tears into the upper slab's own color, and the dashed slab boundary at row 2400 has nothing to show. A 10 px flat band sits at each slab end.

The rule outlived the feature: judge stacked art at true device scale, one screen cropped at the seam. A compressed montage hides the vertical artifacts that matter.

The real render at seed 42, desert over sea. Left: both slabs, 1080 by 4800 pixels, shown at about 1 to 5.3. Right: rows 2340 to 3240 at 1 to 1, with a cyan line on the slab boundary at row 2400. The sand runs across the boundary unbroken and the only tear is the sea shore below it. Both slabs match the pinned sha256.

The app’s end of the deal

React Native holds up the last piece. The images render in an Image with explicit width and height, because under React Native’s new architecture (0.83 here) an inset-styled image silently renders at its intrinsic bitmap size, which is how I got a 1080x2400 slab drawn at device points, a zoom into its top-left corner. Locked tiers show a pre-desaturated _gray variant shipped alongside each biome, cheaper and more consistent than runtime tinting.

Same seed, same bytes, every render. That is what days with a diffusion model could not give me.

If you are pinning generative art the same way, write me: contact@markostankovic.org.