Anisotropic Kuwahara Filter for Canvas Painting
Turn a generated geometric still into directional painterly regions while keeping tensor, variance, sector, and boundary diagnostics visible.
An anisotropic Kuwahara filter turns local direction and variance into painterly regions while trying to preserve strong boundaries. This tutorial applies a bounded educational approximation to a generated geometric still and keeps gradients, tensor orientation, sectors, variance, and boundary error visible beside the painting.
Anisotropic Kuwahara filter starts with direction
A classic Kuwahara-style filter compares neighborhood regions and favors statistics from a relatively uniform sector, reducing noise while preserving some edges. The anisotropic extension follows local structure so the sampling footprint stretches along image direction instead of remaining a fixed circular window.
The publication on anisotropic Kuwahara filtering is the primary conceptual reference. This tutorial implements only a bounded educational approximation and must not imply parity with the paper's complete GPU method or parameterization.
The source image is generated from geometric bands and a circle, not scraped photography. Every anisotropic Kuwahara filter result therefore has known rights and a repeatable input, while its receipt labels orientation, variance, boundary diagnostics, and approximation choices.
The illustrative source still is generated as stripes, a circle, and three rights-clear colors on a 64 by 36 pixel buffer. The painterly image filter reads those pixels; it no longer paints ellipses that merely resemble brush strokes.
Create a source that exposes filter mistakes
Paint flat regions, gentle gradients, diagonal edges, a curved boundary, narrow lines, intersections, small accents, and high-frequency texture. Use a fixed palette and dimensions so lost boundaries, directional smearing, halos, and block artifacts remain easy to identify.
Retain the source pixel buffer and digest before any Canvas image processing. Premultiplication, color-space conversion, alpha, device scaling, and image smoothing should be explicit because reading and writing pixels can otherwise change evidence outside the filter.
Compare the generated source with Canvas blend modes only after filtering is frozen. Layer composition can enrich a painted edition, but it should not hide a boundary error introduced by the anisotropic Kuwahara filter.
Central luminance differences produce horizontal and vertical gradients. Their products form a local structure tensor, from which orientation and an anisotropy ratio are calculated for every output pixel. Every direction comes from source data itself. Flat regions keep finite orientation values through the documented denominator guard.
Derive gradients and a smoothed structure tensor
Compute horizontal and vertical color or luminance gradients with a documented kernel and boundary rule. Build tensor components from squared and cross products, then smooth those components over a local neighborhood before extracting eigenvalues and orientation.
The dominant eigenvector indicates the direction of greatest change; the tangent direction often guides an elongated kernel along the edge. Derive an anisotropy measure from the eigenvalue contrast, guarding flat regions and small denominators so orientation remains finite.
The anisotropic Kuwahara filter tensor map should be viewable as glyphs or a direction field with a text legend. Direction is periodic, so avoid a color wheel as the only explanation and show representative numeric angles for selected pixels.
An oriented ellipse bounds four sampling sectors. Each sector accumulates RGB mean and luminance variance, and the output selects the mean from the lowest-variance sector rather than drawing a precomposed mark. This is the computed edge-preserving abstraction mechanism used by every preset.
| Gradient | central luminance differences gx and gy |
|---|---|
| Tensor | Jxx, Jxy, and Jyy |
| Orientation | half-angle from the symmetric tensor |
| Anisotropy | eigenvalue contrast divided by trace |
Stretch and rotate the sampling footprint
Map each output pixel's neighborhood through an ellipse whose aspect ratio follows local anisotropy and whose orientation follows the structure tensor. Keep radius, maximum elongation, sector count, smoothing, and boundary sampling policy in the preset.
An enormous footprint can erase small intentional forms, while an undersized one reduces painterly cohesion. Choose bounded presets for fine, medium, and broad strokes, then compare them on exactly the same source rather than changing image and parameters together.
An anisotropic Kuwahara filter is not a brush simulation. The ellipse organizes statistical sampling; material texture, pigment accumulation, bristle marks, and human gesture require different models or separate authored layers.
Ink, soft, and broad presets freeze radii two, three, and four. Their sector count stays four so radius effects remain interpretable; a complete paper implementation with polynomial weighting and tensor smoothing is not claimed. Preset scope remains visible in every receipt.
Runnable artifact — Filter a rights-clear geometric still through computed gradients, tensors, oriented sectors, variance selection, and boundary diagnostics.
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Anisotropic Kuwahara painting studio</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{font:16px/1.5 system-ui;max-width:960px;margin:auto;padding:24px;background:#101b24;color:#f5f3ea}button,select,a{font:inherit;padding:.72rem 1rem;margin:.35rem;border:2px solid #7ce5c3;border-radius:.6rem;background:#172b36;color:#fff}button:focus-visible,select:focus-visible,a:focus-visible{outline:4px solid #ffd66b;outline-offset:3px}canvas{width:100%;height:auto;border:1px solid #78909c;background:#081116}output{display:block;white-space:pre-wrap;padding:1rem;background:#081116;border-radius:.6rem;margin-top:1rem;overflow-wrap:anywhere}small{display:block;color:#b8cbd4}.exports{display:flex;flex-wrap:wrap;gap:.5rem}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}</style><main><h1>Anisotropic Kuwahara painting studio</h1><p>A generated still passes through a bounded CPU approximation with real gradients, tensors, oriented sectors, variance selection, and boundary diagnostics.</p><select id="preset" aria-label="Filter preset"><option value="ink">Ink</option><option value="soft" selected>Soft</option><option value="broad">Broad</option></select><button id="render">Render filter</button><canvas id="painting" width="640" height="360" aria-label="Source, filtered, orientation, and variance-boundary maps"></canvas><div class="exports"><a id="pngExport" download="kuwahara.png">Export PNG</a><a id="jsonExport" download="kuwahara.json">Export diagnostics</a></div><output id="receipt" aria-live="polite"></output></main><script>const W=64,H=36,presets={ink:{radius:2,sectors:4},soft:{radius:3,sectors:4},broad:{radius:4,sectors:4}},idx=(x,y)=>(y*W+x)*4,clamp=(v,a,b)=>Math.max(a,Math.min(b,v)),sha=async bytes=>[...new Uint8Array(await crypto.subtle.digest("SHA-256",bytes))].map(x=>x.toString(16).padStart(2,"0")).join("");function source(){const out=new Uint8ClampedArray(W*H*4);for(let y=0;y<H;y++)for(let x=0;x<W;x++){const i=idx(x,y),circle=(x-40)**2+(y-17)**2<95,stripe=Math.sin((x+y)*.45)>.15;out.set(circle?[240,174,72,255]:stripe?[44,148,142,255]:[24,49,82,255],i)}return out}const lum=(p,i)=>.2126*p[i]+.7152*p[i+1]+.0722*p[i+2];function compute(input,params){const orientation=new Float32Array(W*H),anisotropy=new Float32Array(W*H),variance=new Float32Array(W*H),boundary=new Uint8Array(W*H),output=new Uint8ClampedArray(input.length);for(let y=0;y<H;y++)for(let x=0;x<W;x++){const xm=clamp(x-1,0,W-1),xp=clamp(x+1,0,W-1),ym=clamp(y-1,0,H-1),yp=clamp(y+1,0,H-1),gx=lum(input,idx(xp,y))-lum(input,idx(xm,y)),gy=lum(input,idx(x,yp))-lum(input,idx(x,ym)),jxx=gx*gx,jxy=gx*gy,jyy=gy*gy,theta=.5*Math.atan2(2*jxy,jxx-jyy),trace=jxx+jyy,delta=Math.sqrt((jxx-jyy)**2+4*jxy*jxy),a=delta/(trace+1e-6),pi=y*W+x;orientation[pi]=theta;anisotropy[pi]=a;boundary[pi]=Math.hypot(gx,gy)>70?1:0;const stats=Array.from({length:params.sectors},()=>({sum:[0,0,0],sumL:0,sumL2:0,n:0}));for(let oy=-params.radius;oy<=params.radius;oy++)for(let ox=-params.radius;ox<=params.radius;ox++){const rx=Math.cos(theta)*ox+Math.sin(theta)*oy,ry=-Math.sin(theta)*ox+Math.cos(theta)*oy;if((rx/(params.radius*(1+a*.7)))**2+(ry/(params.radius*(1-a*.35)+.01))**2>1)continue;const sector=(rx>=0?0:2)+(ry>=0?0:1),sx=clamp(x+ox,0,W-1),sy=clamp(y+oy,0,H-1),si=idx(sx,sy),l=lum(input,si),s=stats[sector];s.sum[0]+=input[si];s.sum[1]+=input[si+1];s.sum[2]+=input[si+2];s.sumL+=l;s.sumL2+=l*l;s.n++}const measured=stats.map(s=>({s,variance:s.n?s.sumL2/s.n-(s.sumL/s.n)**2:Number.MAX_VALUE})).sort((m,n)=>m.variance-n.variance)[0];variance[pi]=Number.isFinite(measured.variance)?measured.variance:0;for(let c=0;c<3;c++)output[idx(x,y)+c]=measured.s.n?measured.s.sum[c]/measured.s.n:input[idx(x,y)+c];output[idx(x,y)+3]=255}return{output,orientation,anisotropy,variance,boundary}}function paint(input,data){const ctx=painting.getContext("2d"),panels=[input,data.output,null,null],labels=["source","filtered","orientation","variance + boundary"];panels.forEach((pixels,p)=>{const image=ctx.createImageData(W,H);for(let i=0;i<W*H;i++){if(p<2)image.data.set(pixels.slice(i*4,i*4+4),i*4);else if(p===2){const q=(data.orientation[i]+Math.PI/2)/Math.PI;image.data.set([40+q*180,80+data.anisotropy[i]*150,210-q*120,255],i*4)}else{const q=Math.min(1,data.variance[i]/2500),b=data.boundary[i];image.data.set([b?255:40+q*180,b?214:50,80+q*120,255],i*4)}}const temp=document.createElement("canvas");temp.width=W;temp.height=H;temp.getContext("2d").putImageData(image,0,0);const ox=(p%2)*320,oy=Math.floor(p/2)*180;ctx.imageSmoothingEnabled=false;ctx.drawImage(temp,ox,oy,320,180);ctx.fillStyle="#fff";ctx.fillText(labels[p],ox+8,oy+16)})}async function execute(){try{const params=presets[preset.value],input=source(),data=compute(input,params);paint(input,data);const png=await new Promise(r=>painting.toBlob(r,"image/png")),diagnostics={fixture:"generated geometric still",preset:preset.value,parameters:params,maps:["source","filtered","orientation","anisotropy","variance","boundary"],orientationRange:[Math.min(...data.orientation),Math.max(...data.orientation)],anisotropyRange:[Math.min(...data.anisotropy),Math.max(...data.anisotropy)],varianceRange:[Math.min(...data.variance),Math.max(...data.variance)],boundaryPixels:data.boundary.reduce((a,b)=>a+b,0),finite:[...data.orientation,...data.anisotropy,...data.variance].every(Number.isFinite),approximation:"bounded CPU four-sector filter; not the papers complete GPU formulation"},json=new Blob([JSON.stringify(diagnostics,null,2)],{type:"application/json"});pngExport.href=URL.createObjectURL(png);jsonExport.href=URL.createObjectURL(json);diagnostics.exports={png:{bytes:png.size,sha256:await sha(await png.arrayBuffer())},json:{bytes:json.size,sha256:await sha(await json.arrayBuffer())}};const pass=diagnostics.finite&&diagnostics.boundaryPixels>0&&diagnostics.varianceRange[1]>diagnostics.varianceRange[0];receipt.dataset.execution=JSON.stringify(diagnostics);receipt.value=(pass?"PASS: ":"FAIL: ")+JSON.stringify(diagnostics,null,2)}catch(error){receipt.dataset.execution=JSON.stringify({unexpectedError:error.name+": "+error.message});receipt.value="FAIL: unexpected "+error.message}}render.onclick=()=>void execute();void execute()</script></html>
- Rotate each offset by the tensor orientation.
- Reject samples outside the anisotropic ellipse.
- Assign accepted samples to one of four sectors.
- Compute RGB means and luminance variances.
- Select the mean belonging to the minimum-variance sector.
Select or blend sector means by variance
Partition the oriented footprint into overlapping sectors, accumulate weighted color moments, derive a mean and variance for each, and combine sector means with weights that favor low variance. A hard minimum creates crisp selection, while polynomial weighting can produce smoother responses.
The polynomial weighting paper gives primary context for improved weighting functions. Record the formula and exponent used; changing it can shift edge behavior even when radius and tensor stay constant.
The anisotropic Kuwahara filter fixture's Node test derives one finite tensor orientation and selects the minimum value from a tiny variance list. That is a unit proof of two ingredients, not a validation of the complete image filter.
The Canvas shows source, filtered result, orientation plus anisotropy, and variance with boundary mask. Those maps are computed arrays from the same run, not legend labels attached to unrelated geometry. Preset selection regenerates all four panels from one source buffer.
Treat boundaries as a measured artistic constraint
Create a source edge mask and compare edge position, local contrast, or signed gradient after filtering. Show where a boundary moved, weakened beyond a declared threshold, or acquired a halo; preserve the raw diagnostic rather than painting it into the final image.
Metrics describe the generated fixture and cannot establish human perceptual quality. A maker may accept softened texture while rejecting a shifted focal outline even if aggregate error is low, so keep region-specific constraints and visual critique together.
The seam-carving art-direction workflow offers a related principle: protect authored structure during an image operation. The anisotropic Kuwahara filter uses local statistics rather than path removal, but both need explicit refusal conditions.
A high gradient threshold marks boundary pixels, and the receipt requires at least one. The variance range must also have nonzero spread, preventing an empty diagnostic map from passing as anisotropic Kuwahara filter evidence. Boundary counts remain diagnostic evidence, never a universal perceptual-quality score.
- Source
- Generated stripes, circle, and three-color still.
- Filtered
- Minimum-variance sector means.
- Direction
- Orientation encoded with anisotropy strength.
- Diagnostic
- Variance field plus thresholded gradient boundary.
- Export
- Combined PNG and parameter JSON with SHA-256.
Render diagnostics beside the Canvas painting
Show source, painted output, orientation field, anisotropy, selected-sector or weight map, variance heatmap, and boundary-error mask at matched scale. Let keyboard users select a preset, provide focus feedback, and avoid animated transitions when reduced motion is requested.
The Canvas 2D standard defines pixel access and rendering behavior. Use a worker for heavier experiments only after the scalar reference and worker outputs agree within declared numeric and image tolerances.
Contrast this anisotropic Kuwahara filter with domain-warped marble: the former abstracts a source image using local direction and variance, while the latter generates a field directly. Similar flowing marks do not imply the same algorithm.
Orientation, anisotropy, and variance arrays are scanned for finite numbers. Edge sampling clamps to the generated image, which is a disclosed boundary policy rather than an implicit read outside the source. The rendered diagnostic legends expose the observed numeric ranges beside each map.
Export a painterly study with its evidence
Archive source generator, source digest, dimensions, color handling, gradient kernel, tensor smoothing, radius, anisotropy mapping, sectors, weights, boundary rule, diagnostic thresholds, implementation version, output digest, and browser identity. Export the untouched source and all diagnostic maps beside the painted PNG.
Use optical-flow typography only when direction comes from temporal motion; a static structure tensor answers a different question. Naming the field correctly protects the artistic concept from technical vagueness.
Render one anisotropic Kuwahara filter preset, inspect its variance and boundary masks, then decide whether the abstraction supports the composition. The edition is complete when technical limits stay adjacent to the beauty they made possible, not hidden beneath it.
PNG and diagnostic JSON exports receive SHA-256 digests and byte counts. The JSON names preset, parameters, map inventory, ranges, boundary count, and the bounded CPU approximation. Export parity is asserted before reporting success. Both files identify the same selected radius and sector count.
Test borders color and precision deliberately
Neighborhood filters fail visibly at image edges when sampling wraps, clamps, mirrors, pads, or skips without a documented rule. Create border fixtures containing a diagonal line, flat color, corner impulse, and alpha transition, then inspect both output and diagnostic maps; a beautiful center cannot compensate for a dark frame introduced by invalid samples.
Run numeric checks for finite tensor components, bounded weights, non-negative variance, normalized sector contribution, and output color range. Decide whether statistics operate in encoded RGB, linear light, luminance, or another space, and state that choice because means and variances change with the representation.
Anisotropic Kuwahara filter exports should preserve bit depth and color profile assumptions. Compare a small scalar reference with any optimized worker or GPU path under tolerances, then retain a difference image so performance work cannot quietly alter edge selection or palette.
The CTA now promises the painting studio and diagnostic export that actually exist. It does not advertise a separate variance-map download beyond the combined rendered panel and JSON fields.
Build a coherent series from diagnostic variation
Freeze one source and vary radius, anisotropy, sector weighting, and tensor smoothing one axis at a time. Arrange outputs as a grid with shared crop and palette, then annotate which changes broaden strokes, align regions, soften boundaries, or expose halos; this creates an artistic vocabulary grounded in traceable parameters.
Choose a final preset through compositional critique, not the lowest aggregate variance. A broad filter may create satisfying masses while erasing the focal circle, and a fine filter may preserve geometry while leaving distracting noise; protected-region diagnostics make that judgment explicit rather than pretending the algorithm owns taste.
Anisotropic Kuwahara filter work becomes a series when every image retains source digest, preset, implementation, and boundary mask. The maker can then sequence delicate and aggressive abstractions, print them at matched scale, and explain how direction became paint without claiming a hidden hand-painted process.
The February review compares all frozen presets and reruns export hashes. Any correction to tensor, sector, or boundary math creates a new evidence edition rather than silently replacing the previous anisotropic Kuwahara filter output.