Lesson 15 — Pixel Processing
Pixel processing gives you direct access to the raw colour data of any raster layer. This lesson covers the full pipeline: getting a bitmap from a raster node, reading and writing individual pixels, creating bitmaps from scratch, loading images from disk, and rendering any node to a bitmap. It also covers the colour formats and the Buffer class for raw binary data.
Pixel-level access unlocks algorithms the high-level SDK can't express — custom colour grading, per-pixel analysis, procedural textures, histograms, and spatial effects. If you can describe it mathematically, you can implement it here.
Two things are easy to get wrong, so keep them in mind:
- Pixel access starts with a
Bitmap, not the node. Get a bitmap via the node'sbitmapproperty,Bitmap.loadFromFile(), orNodeRenderingEngine. Reading pixel data directly from a node doesn't work. - Pixels expose alpha as
px.alpha, notpx.a. Code that readspx.areturnsundefinedsilently, producing subtle bugs. TheRGBA8constructor usesr, g, b, alphatoo.
Performance matters in pixel loops — iterating millions of pixels naively can make the app feel unresponsive. Work on smaller bitmaps, use typed access formats, and structure loops to minimise repeated lookups.
By the end you'll have a working mental model of the pixel pipeline — node → bitmap → reader/writer → back — plus working examples of inversion, grayscale, procedural generation, and histograms to use as templates.
What this lesson covers
- What a pixel is and when to use pixel processing vs built-in filters
- The complete pixel pipeline: bitmap → pixel accessor → write back
- All 10 colour formats and how to choose the right one
- Reading and writing individual pixels
- Creating bitmaps from scratch and loading from file
- Rendering any node to a bitmap with
NodeRenderingEngine - The
Bufferclass for raw binary data - Performance considerations for pixel loops
- Real-world patterns: histogram analysis, posterisation, procedural generation
What a pixel is in this context
When you open an image in Affinity, what you see on screen is a rendered representation. The underlying data is a rectangular grid of numbers — each cell in the grid is a pixel, and each pixel is a small set of channel values that describe its colour.
For an RGBA8 image — the most common format — each pixel has four channels: red, green, blue, and alpha (transparency). Each channel is an 8-bit integer: a whole number between 0 and 255. A pure red pixel is { r: 255, g: 0, b: 0, alpha: 255 }. A 50% transparent mid-grey is { r: 128, g: 128, b: 128, alpha: 128 }. That's all a pixel is — a small structure of numbers in a large grid.
Pixel processing means reading those numbers, computing new ones, and writing them back. Any operation you can express as a mathematical transformation on those channel values is something you can implement as a pixel script.
Why pixel processing rather than filters or adjustments
Affinity's built-in filters and adjustment layers cover a wide range of common operations — levels, curves, HSL, blur, noise — and they run fast because they execute in C++ on optimised code paths. For those operations, you should always prefer the built-in tools.
Pixel processing becomes the right choice when you need something the built-in tools cannot express:
- Custom algorithms — a specific colour grading formula, a specialised sharpening kernel, a per-pixel mathematical transform that isn't in the filter list.
- Analysis without editing — reading pixel data to compute statistics, detect clipping, measure average colour, or build a histogram. The built-in tools don't expose this data to scripts.
- Conditional processing — applying different transforms to different regions of the image based on pixel values. For example: desaturate only pixels where red dominates, or boost contrast only in shadow regions.
- Procedural generation — creating images from scratch using mathematical functions, noise algorithms, or data from outside the document.
- Format-specific operations — working directly in CMYK or LAB colour space without a conversion round-trip.
Why pixel processing is slow — and what to do about it
JavaScript is not a fast language for numerical loops. A 4000×3000 image has 12 million pixels. Visiting each one, calling readPixel, performing arithmetic, and calling writePixel takes measurably longer in JavaScript than the same operation in C++ — potentially several seconds for a large image.
This doesn't mean pixel processing is unusable, but it does mean you should think before writing a loop:
- Do you need every pixel? Analysis tasks often work well on a sampled grid — visiting every 4th pixel in each direction gives you 1/16th the work with results that are still statistically representative.
- Do you need to write back? Read-only analysis (histograms, colour statistics) doesn't need
createReplaceBitmap. Skip the write-back entirely. - Can you work at a smaller size? If the script's effect is not detail-sensitive, export a scaled-down version, process it, and re-import.
- Is there a built-in alternative? A Curves adjustment layer that approximates your formula will always be faster than a per-pixel script. Use pixel processing for what the SDK genuinely can't do otherwise.
Why pixel processing matters
Scripts can read raster metadata (width, height, format) and write pixels through high-level operations (filters, adjustments, AI). Pixel processing drops below all of that and gives you direct access to the numbers. You can implement any image processing algorithm you want, entirely in JavaScript, with results pushed back as undoable commands.
The pipeline
node.rasterInterface
↓
.createCompatibleBitmap(true) ← copy current pixels into a writable bitmap
↓
PixelReaderWriterRGBA8.create(bitmap) ← create a typed accessor
↓
rw.readPixel(x, y) / rw.writePixel(x, y, pixel)
↓
DocumentCommand.createReplaceBitmap(selection, bitmap) ← push back (undoable)
Every step is explicit. The bitmap is a detached copy — you never modify live document
pixels directly. The final createReplaceBitmap command is what actually changes the
document, and it sits in the undo stack like any other operation.
Choosing the right format
Before creating a pixel accessor, check the node's format:
'use strict';
const { Document } = require('/document.js');
const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const node = doc.selection.nodes.filter(n => n.isRasterNode || n.isImageNode).first;
if (!node) {
console.log('Select a raster or image node first.');
} else {
const ri = node.rasterInterface;
const format = ri.format; // RasterFormat enum value
console.log('Format:', format);
console.log('Width: ', ri.width, 'Height:', ri.height);
}
}
Then match the accessor class to the format:
RasterFormat value | Reader class | ReaderWriter class | Pixel object shape |
|---|---|---|---|
RGBA8 | PixelReaderRGBA8 | PixelReaderWriterRGBA8 | { r, g, b, alpha } 0–255 |
RGBA16 | PixelReaderRGBA16 | PixelReaderWriterRGBA16 | { r, g, b, alpha } 0–65535 |
IA8 | PixelReaderIA8 | PixelReaderWriterIA8 | { i, alpha } 0–255 |
IA16 | PixelReaderIA16 | PixelReaderWriterIA16 | { i, alpha } 0–65535 |
CMYKA8 | PixelReaderCMYKA8 | PixelReaderWriterCMYKA8 | { c, m, y, k, alpha } 0–255 |
LABA16 | PixelReaderLABA16 | PixelReaderWriterLABA16 | { l, a_, b_, alpha } |
M8 | PixelReaderM8 | PixelReaderWriterM8 | { m } 0–255 |
M16 | PixelReaderM16 | PixelReaderWriterM16 | { m } 0–65535 |
RGBAuf | PixelReaderRGBAuf | PixelReaderWriterRGBAuf | { r, g, b, alpha } float |
Mf | PixelReaderMf | PixelReaderWriterMf | { m } float |
Mismatching format and accessor throws
INVALID_ARGS. Always checkri.formatbefore choosing your accessor class. The format is an enum object — useString(ri.format)to compare:String(ri.format) === 'RGBA8'.
Core pixel read/write pattern
The pipeline always follows the same five steps: get the raster interface, copy pixels into a writable bitmap, create a typed accessor, process pixels, push back with createReplaceBitmap.
// Snippet
const ri = node.rasterInterface;
const bitmap = ri.createCompatibleBitmap(true); // true = copy current pixels
const rw = PixelReaderWriterRGBA8.create(bitmap);
for (let y = 0; y < ri.height; y++) {
for (let x = 0; x < ri.width; x++) {
const px = rw.readPixel(x, y); // { r, g, b, alpha } — 'alpha' not 'a'
rw.writePixel(x, y, { /* modified values */ });
}
}
doc.executeCommand(DocumentCommand.createReplaceBitmap(doc.selection, bitmap));
See 14a-pixel-inverter.js for a complete runnable example using this pattern.
Read-only analysis (no write-back needed)
When you only need to inspect pixels — colour sampling, histogram, dominant colour — use
the read-only PixelReaderRGBA8 (no Writer in the name). It doesn't need a writable
bitmap.
'use strict';
const { Document } = require('/document.js');
const { PixelReaderRGBA8 } = require('/pixelaccessor.js');
const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const node = doc.selection.nodes.filter(n => n.isRasterNode || n.isImageNode).first;
if (!node) { console.log('Select a raster or image node.'); }
else {
const ri = node.rasterInterface;
const bitmap = ri.createCompatibleBitmap(true);
const reader = PixelReaderRGBA8.create(bitmap);
// Sample a 5×5 grid across the image
const w = ri.width, h = ri.height;
let totalR = 0, totalG = 0, totalB = 0, count = 0;
for (let gy = 0; gy < 5; gy++) {
for (let gx = 0; gx < 5; gx++) {
const x = Math.floor(gx * w / 5);
const y = Math.floor(gy * h / 5);
const px = reader.readPixel(x, y);
totalR += px.r; totalG += px.g; totalB += px.b;
count++;
}
}
console.log('Average colour:',
Math.round(totalR/count),
Math.round(totalG/count),
Math.round(totalB/count)
);
}
}
Creating a bitmap from scratch
You don't need an existing node to start. The Bitmap class lets you create one from
dimensions and format, or load one from a file:
'use strict';
const { Document } = require('/document.js');
const { Bitmap, RasterFormat } = require('/rasterobject.js');
const { RasterNodeDefinition } = require('/nodes.js');
const { PixelReaderWriterRGBA8 } = require('/pixelaccessor.js');
const { AddChildNodesCommandBuilder } = require('/commands.js');
const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
// Create a new 200×200 RGBA8 bitmap
const bitmap = Bitmap.create(200, 200, RasterFormat.RGBA8);
const rw = PixelReaderWriterRGBA8.create(bitmap);
// Draw a gradient
for (let y = 0; y < 200; y++) {
for (let x = 0; x < 200; x++) {
rw.writePixel(x, y, {
r: Math.round(x / 200 * 255),
g: Math.round(y / 200 * 255),
b: 128,
alpha: 255
});
}
}
// Add it to the document as a new raster node
const nodeDef = RasterNodeDefinition.create(RasterFormat.RGBA8);
nodeDef.bitmap = bitmap;
const builder = AddChildNodesCommandBuilder.create();
builder.addRasterNode(nodeDef);
doc.executeCommand(builder.createCommand());
console.log('Gradient raster node added.');
}
PatternRasterNodeDefinition tiles a bitmap as a repeating pattern: PatternRasterNodeDefinition.create(bitmap, transform, mirror) builds from an explicit bitmap and transform with mirroring control, or createDefault(doc) for the defaults.
Loading a bitmap from a file
Bitmap.loadFromFile reads any image from the Desktop into a Bitmap object.
The file must exist — it throws a generic ERROR if the path is wrong or the file is missing.
'use strict';
const { Document, FileExportOptions, FileExportArea } = require('/document.js');
const { Bitmap, RasterFormat } = require('/rasterobject.js');
const { app } = require('/application.js');
const doc = Document.current;
const desktop = app.userDesktopPath;
if (!doc) { console.log('No document open.'); }
else {
// Export the current document as PNG first, then load it back as a Bitmap
const outPath = `${desktop}/sdk_bitmap_test.png`;
const opts = FileExportOptions.createWithPresetName('PNG');
const area = FileExportArea.createForWholeDocument();
const records = doc.export(outPath, opts, area, null);
if (!records.all[0]?.isSuccess) {
console.log('Export failed — cannot load.');
} else {
// Now load it back
const bitmap = Bitmap.loadFromFile(outPath, RasterFormat.RGBA8);
console.log('Loaded:', bitmap.width, '×', bitmap.height);
// bitmap.format returns a plain integer (0 = RGBA8, 1 = RGBA16, etc.)
// Use RasterFormat.keys/values to look up the name
const fmtKeys = RasterFormat.keys.toString().split(',');
const fmtVals = RasterFormat.values.toString().split(',').map(Number);
const fmtIdx = fmtVals.indexOf(bitmap.format);
const fmtName = fmtIdx !== -1 ? fmtKeys[fmtIdx] : String(bitmap.format);
console.log('Format:', fmtName); // e.g. 'RGBA8'
}
}
Desktop + granted folders restriction applies.
Bitmap.loadFromFilecan only read files from the Desktop or folders you explicitly grant access to. Any other path throws a genericERROR. Always useapp.userDesktopPathas your base path.
Rendering a node to a bitmap with NodeRenderingEngine
This lets you render any node — including vector, text, or group nodes — to pixels. Useful for generating thumbnails, doing pixel analysis on non-raster nodes, or compositing.
'use strict';
const { Document } = require('/document.js');
const { NodeRenderingEngine, RasterFormat } = require('/rasterobject.js');
const { PixelReaderRGBA8 } = require('/pixelaccessor.js');
const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const node = doc.selection.nodes.first;
if (!node) { console.log('Select any node first.'); }
else {
const engine = NodeRenderingEngine.createDefault(node, RasterFormat.RGBA8);
// engine has width/height from the node's bounding box
const bitmap = engine.createCompatibleBitmap(true);
const reader = PixelReaderRGBA8.create(bitmap);
// Sample the centre pixel
const cx = Math.floor(engine.width / 2);
const cy = Math.floor(engine.height / 2);
console.log('Centre pixel:', reader.readPixel(cx, cy));
}
}
The Buffer class
Buffer is a raw byte container — not directly for pixel data (use Bitmap or PixelBuffer
for that), but useful for binary I/O, encoding text, or building binary structures.
'use strict';
const { Buffer } = require('/buffer.js');
// Create and fill
const buf = Buffer.create(4);
const arr = buf.array; // Uint8Array view
arr[0] = 0xFF; arr[1] = 0x00; arr[2] = 0x80; arr[3] = 0xFF;
// UTF-8 encode a string
const utf8buf = Buffer.utf8('Hello, Affinity!');
console.log('Byte length:', utf8buf.size);
console.log('As string: ', utf8buf.toString());
// Concatenate
const combined = buf.concat(utf8buf);
console.log('Combined size:', combined.size);
// Slice (copy)
const sliced = buf.slice(0, 2);
// ArrayBuffer for interop with JS typed arrays
const ab = buf.arrayBuffer; // standard ArrayBuffer
Buffer also offers compareSome(), toString(enc, start, end) overloads, and the array / items views alongside arrayBuffer. For rendering control, NodeRenderingEngine.create(node, format, options) accepts a NodeRenderingEngineOptions object (create() it first): down/up resampler types, antialiasing, gradient dithering, clipping, and isolated-rendering flags — createDefault() is just the presets version.
PixelBuffer — pixel data without a GPU bitmap
PixelBuffer is a CPU-side pixel array, useful when you want the raw bytes without the
overhead of a full Bitmap:
'use strict';
const { RGBA8 } = require('/colours.js');
const { PixelBuffer, RasterFormat } = require('/rasterobject.js');
const pb = PixelBuffer.create(100, 100, RasterFormat.RGBA8);
const ab = pb.buffer; // raw ArrayBuffer — 100×100×4 bytes
const u8 = new Uint8Array(ab);
// Fill with solid red
for (let i = 0; i < u8.length; i += 4) {
u8[i] = 255; // R
u8[i+1] = 0; // G
u8[i+2] = 0; // B
u8[i+3] = 255; // A
}
Performance considerations
JavaScript pixel loops are slow. For large images (> 2000×2000px) a full-image nested for-loop will take several seconds. Strategies to manage this:
- Use
PixelReaderWriterRGBA8not a generic accessor — the typed classes have direct C++ backing. - Work on a sampled grid rather than every pixel when analysis (not editing) is the goal.
- Use
doc.executeCommandAsyncto avoid blocking the UI during heavy processing. - Prefer the SDK's built-in filters (adjustments, layer effects) for common operations — they run in C++ at full speed. Drop to pixel level only when you need something the SDK doesn't offer natively.
Key gotchas
alpha not a — on both read and write. readPixel returns { r, g, b, alpha } — the key is alpha, not a. Using px.a reads undefined. On writePixel, passing { r, g, b, a: someValue } silently ignores the value and writes 255 (fully opaque) instead — no error is thrown. Always use alpha as the key name on both sides.
createReplaceBitmap requires a raster document, not a placed image. Open the image as a document (File > Open) rather than placing it on a page. A placed image has isImageNode: true — createReplaceBitmap silently does nothing on it. Rasterise first via DocumentCommand.createRasteriseObjects(doc.selection, false, false) if you need to process a placed image in-place.
Format mismatch gives corrupt data silently. Always check ri.format first. If the node is CMYK and you use PixelReaderWriterRGBA8, you'll read wrong values and write corrupted channels with no error message.
createCompatibleBitmap(false) gives an uninitialized bitmap. Pass true to copy existing pixel content. Pass false only when you're going to fill every pixel yourself (e.g. procedural generation).
PixelBuffer vs Bitmap — a PixelBuffer is a straight byte array for raw bytes; a Bitmap has GPU backing for rendering and is what you pass to pixel accessors (PixelReaderWriterRGBA8.create(bitmap)).
Desktop + granted folders for file loading. Bitmap.loadFromFile follows the same Desktop and granted-folder restriction as fs — other paths are not accessible.
Raster pixel selection
The SDK exposes commands for working with the pixel-level selection (the "marching ants" selection in Affinity Photo). These are distinct from the node selection used elsewhere.
'use strict';
const { Document } = require('/document.js');
const { DocumentCommand, RasterSelectionLogicalOperation } = require('/commands.js');
const doc = Document.current;
if (doc) {
// Check current pixel selection state
const rsel = doc.rasterSelection;
console.log('Has pixel selection:', !rsel.isSelectNone);
console.log('Is select-all/none:', rsel.isSelectAllOrNone);
console.log('Is current pixel sel:', rsel.isCurrentPixelSelection);
// Select all pixels
doc.rasterSelectAll();
// Invert the pixel selection
doc.rasterInvertSelection();
// Deselect all
doc.rasterDeselect();
// Reselect last selection
doc.rasterReselect();
// Select a polygon region (array of {x, y} points)
const polygon = [
{ x: 100, y: 100 },
{ x: 300, y: 100 },
{ x: 300, y: 300 },
{ x: 100, y: 300 },
];
doc.setRasterSelectionFromPolygon(
polygon,
RasterSelectionLogicalOperation.New, // New / Add / Subtract / Intersect
true, // antialias
0 // feather radius (pixels)
);
// Raster selection refinement
doc.growShrinkRasterSelection(10, false); // expand by 10px (circular=false = square)
doc.featherRasterSelection(5); // soften edge by 5px
doc.smoothRasterSelection(3); // smooth boundary by 3px
doc.outlineRasterSelection(8, alignment, false); // outline at radius 8px
// Select based on a node's alpha channel (use intensity=false for binary)
doc.setRasterSelectionFromObject(node, false, RasterSelectionLogicalOperation.New);
}
doc.rasterSelection returns a RasterSelection object with read-only state — isCurrentPixelSelection, isSelectAllOrNone, and isSelectNone. Check these before mutating: for example, the auto-fix section below guards with !doc.rasterSelection.isSelectNone so one-click corrections never run against an empty selection. The modification commands (setRasterSelectionFromPolygon, setRasterSelectionFromObject, rasterSelectAll, rasterDeselect, etc.) are all on doc directly. RasterSelectionLogicalOperation controls how a new selection combines with the existing one: New (0), Add (1), Subtract (2), Intersect (3) — there is no Replace value.
Both
setRasterSelectionFromPolygonandrasterSelectPolygonwork — prefersetRasterSelectionFromPolygon.
Magic wand (flood select): createRasterFloodSelect(x, y, tolerance, antialias, feather) performs a flood-fill raster selection starting at pixel (x, y), similar to the Magic Wand tool in the UI. Use positive tolerance for similar-colour selection, lower values for tighter matching.
Raster auto-fixes and fill
One-click tonal corrections and paint-style fills operate on the current raster selection. These are doc convenience methods (with DocumentCommand equivalents).
'use strict';
const { Document } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const doc = Document.current;
if (doc && !doc.rasterSelection.isSelectNone) {
// One-click tonal corrections (operate on the raster selection)
doc.rasterAutoLevels(); // stretch to full tonal range
doc.rasterAutoContrast(); // normalize contrast
doc.rasterAutoColours(); // fix colour cast
doc.rasterAutoWhiteBalance(); // neutralise white balance
// Fill the raster selection (mode = flood-fill style)
// DocumentCommand.createRasterFill(sel, mode, colour, opacity, blendMode);
doc.rasterFill(); // fill with the current foreground colour
// Magic-wand fill starting at a point, with tolerance + contiguity
// DocumentCommand.createRasterFloodFill(sel, point, tolerance, isContiguous,
// antialias, samplingSource, blendMode, colour);
}
createRasterFill(sel, mode, colour, opacity, blendMode) paints the whole selection; createRasterFloodFill(sel, point, tolerance, isContiguous, antialias, samplingSource, blendMode, colour) paints outward from a point, stopping at colour boundaries like the Flood Fill tool.
Exercises
Exercise 15.1 — Colour inverter
See 14a-pixel-inverter.js in lesson 14 for a working implementation.
Exercise 15.2 — Average colour sampler
Select any node (raster, image, or vector). Sample a 10×10 grid of pixels from a rendered bitmap and compute the average R, G, B. Display the result in a console.log.
Exercise 15.3 — Greyscale converter
See 15b-greyscale-converter.js for a working implementation using the luminance formula.
Exercise 15.4 — Procedural gradient node
See 15c-procedural-gradient.js for a working implementation.
Exercise 15.5 — Histogram analysis
See 15e-histogram-analysis.js for a full implementation with per-channel statistics, clipping detection, and tonal distribution.
Exercise 15.6 — Posterise
See 15f-posterise.js for a working implementation with a configurable level count via dialog.
Exercise 15.7 — Node thumbnail
Render the selected (non-raster) node to a bitmap using NodeRenderingEngine, then save it as a PNG to the Desktop using Bitmap and file export.
SDK modules used in this lesson
/pixelaccessor— AllPixelReader*andPixelReaderWriter*classes/commands—DocumentCommand,AddChildNodesCommandBuilder,RasterSelectionLogicalOperation/document—Document/rasterobject—Bitmap,PixelBuffer,NodeRenderingEngine,RasterFormat/rasterselection—RasterSelection(read-only pixel-selection state)/buffer—Buffer/nodes—RasterNodeDefinition
Further examples
| Example | What it shows |
|---|---|
bitmapWriter.js | /rasterobject (Bitmap, PixelBuffer, NodeRenderingEngine), /buffer byte manipulation, pixel writing to a bitmap |
See also
- Lesson 03 — Node Model — how to find raster nodes
- Lesson 04 — Command Pattern — how commands integrate with undo
- Lesson 12 — Layer Effects — fast built-in image operations
- examples/
Example scripts
15a-colour-inverter.js
Download 15a-colour-inverter.js
'use strict';
// Example 14a — Colour inverter
// Lesson 14: Pixel Processing
//
// Inverts all pixel colours in the selected raster/image node.
// Demonstrates the full bitmap pipeline: read → process → write back.
const { Document } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const { PixelReaderWriterRGBA8 } = require('/pixelaccessor.js');
const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const node = doc.selection.nodes.filter(n => n.isRasterNode || n.isImageNode).first;
if (!node) {
console.log('Select a raster or image node and run again.');
} else {
const ri = node.rasterInterface;
const w = ri.width;
const h = ri.height;
console.log(`Node: ${node.descriptionInterface?.description ?? '(unnamed)'}`);
console.log(`Size: ${w}×${h} Format: ${ri.format}`);
if (String(ri.format) !== 'RGBA8') {
console.log('This script requires RGBA8 format. Open an RGBA8 image and run again.');
} else {
// Copy current pixels into a detached, writable bitmap
const bitmap = ri.createCompatibleBitmap(true);
const rw = PixelReaderWriterRGBA8.create(bitmap);
let count = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const px = rw.readPixel(x, y);
rw.writePixel(x, y, {
r: 255 - px.r,
g: 255 - px.g,
b: 255 - px.b,
alpha: px.alpha // preserve alpha channel
});
count++;
}
}
// Push back as a single undoable command
doc.executeCommand(DocumentCommand.createReplaceBitmap(doc.selection, bitmap));
console.log(`Done — inverted ${count} pixels.`);
}
}
}
15b-greyscale-converter.js
Download 15b-greyscale-converter.js
'use strict';
// Example 14b — Greyscale converter
// Lesson 14: Pixel Processing
//
// Converts a selected RGBA8 raster node to greyscale using the
// photometric luminance formula: L = 0.299*R + 0.587*G + 0.114*B
const { Document } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const { PixelReaderWriterRGBA8 } = require('/pixelaccessor.js');
const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const node = doc.selection.nodes.filter(n => n.isRasterNode || n.isImageNode).first;
if (!node) {
console.log('Select a raster or image node and run again.');
} else {
const ri = node.rasterInterface;
const w = ri.width;
const h = ri.height;
if (String(ri.format) !== 'RGBA8') {
console.log('Format is', ri.format, '— this script requires RGBA8. Open an RGBA8 image and run again.');
} else {
const bitmap = ri.createCompatibleBitmap(true);
const rw = PixelReaderWriterRGBA8.create(bitmap);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const px = rw.readPixel(x, y);
// Photometric luminance
const L = Math.round(0.299 * px.r + 0.587 * px.g + 0.114 * px.b);
rw.writePixel(x, y, { r: L, g: L, b: L, alpha: px.alpha });
}
}
doc.executeCommand(DocumentCommand.createReplaceBitmap(doc.selection, bitmap));
console.log(`Converted ${w}×${h} pixels to greyscale.`);
}
}
}
15c-procedural-gradient.js
Download 15c-procedural-gradient.js
'use strict';
const { RGBA8 } = require('/colours.js');
// Example 14c — Procedural gradient node
// Lesson 14: Pixel Processing
//
// Creates a new 512×512 raster node from scratch containing a radial gradient.
// White at the centre, black at the edges.
// Demonstrates creating bitmaps without an existing node.
const { Document } = require('/document.js');
const { AddChildNodesCommandBuilder } = require('/commands.js');
const { Bitmap, RasterFormat } = require('/rasterobject.js');
const { RasterNodeDefinition } = require('/nodes.js');
const { PixelReaderWriterRGBA8 } = require('/pixelaccessor.js');
const W = 512;
const H = 512;
const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
// 1. Create a blank bitmap
const bitmap = Bitmap.create(W, H, RasterFormat.RGBA8);
const rw = PixelReaderWriterRGBA8.create(bitmap);
const cx = W / 2;
const cy = H / 2;
const maxDist = Math.sqrt(cx * cx + cy * cy);
// 2. Fill with radial gradient
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const dx = x - cx;
const dy = y - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
const t = Math.min(dist / maxDist, 1.0); // 0 = centre, 1 = edge
const v = Math.round((1 - t) * 255); // bright centre, dark edge
rw.writePixel(x, y, { r: v, g: v, b: v, alpha: 255 });
}
}
// 3. Wrap in a raster node definition
const nodeDef = RasterNodeDefinition.create(RasterFormat.RGBA8);
nodeDef.bitmap = bitmap;
// 4. Add to document
const builder = AddChildNodesCommandBuilder.create();
builder.addRasterNode(nodeDef);
const cmd = builder.createCommand(true);
doc.executeCommand(cmd);
const newNode = cmd.newNodes[0];
if (newNode) {
newNode.userDescription = 'Radial Gradient';
}
console.log(`Radial gradient (${W}×${H}) added to document.`);
}
15d-histogram.js
'use strict';
// Example 14d — Colour histogram
// Lesson 14: Pixel Processing
//
// Reads every pixel and builds a luminance histogram.
// Reports the 5 most common brightness values and average colour.
const { Document } = require('/document.js');
const { PixelReaderRGBA8 } = require('/pixelaccessor.js');
const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const node = doc.selection.nodes.filter(n => n.isRasterNode || n.isImageNode).first;
if (!node) {
console.log('Select a raster or image node and run again.');
} else {
const ri = node.rasterInterface;
const w = ri.width;
const h = ri.height;
if (String(ri.format) !== 'RGBA8') {
console.log('Format is', ri.format, '— this script requires RGBA8. Open an RGBA8 image and run again.');
} else {
const bitmap = ri.createCompatibleBitmap(true);
const reader = PixelReaderRGBA8.create(bitmap); // read-only, no write needed
const histogram = new Array(256).fill(0);
let totalR = 0, totalG = 0, totalB = 0, opaqueCount = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const px = reader.readPixel(x, y);
if (px.alpha > 0) {
const L = Math.round(0.299 * px.r + 0.587 * px.g + 0.114 * px.b);
histogram[L]++;
totalR += px.r; totalG += px.g; totalB += px.b;
opaqueCount++;
}
}
}
// Find top 5 most common luminance values
const sorted = histogram
.map((count, value) => ({ value, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 5);
console.log(`=== Histogram: ${node.descriptionInterface?.description ?? '(unnamed)'} ===`);
console.log(`Size: ${w}×${h} Opaque pixels: ${opaqueCount}`);
console.log('');
console.log('Top 5 luminance values:');
for (const { value, count } of sorted) {
const bar = '█'.repeat(Math.round(count / opaqueCount * 40));
console.log(` ${String(value).padStart(3)}: ${bar} (${count})`);
}
if (opaqueCount > 0) {
console.log('');
console.log('Average colour:',
Math.round(totalR / opaqueCount),
Math.round(totalG / opaqueCount),
Math.round(totalB / opaqueCount)
);
}
}
}
}
15e-histogram-analysis.js
Download 15e-histogram-analysis.js
'use strict';
// Example 14e — Histogram analysis
// Lesson 14: Pixel Processing
//
// Reads every pixel in the selected raster document and produces a detailed
// tonal and colour report: per-channel statistics, clipping detection,
// shadow/midtone/highlight distribution, and a text-based histogram bar chart.
//
// This is a read-only analysis — no pixels are modified and no write-back is needed.
//
// Open the image as a document (File > Open), select the layer, and run.
const { Document } = require('/document.js');
const { PixelReaderRGBA8 } = require('/pixelaccessor.js');
const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const node = doc.selection.nodes.filter(n => n.isRasterNode || n.isImageNode).first;
if (!node) {
console.log('Select a raster or image node and run again.');
} else {
const ri = node.rasterInterface;
const w = ri.width;
const h = ri.height;
const name = node.descriptionInterface?.description ?? '(unnamed)';
// Read-only accessor — no write-back needed for analysis
if (String(ri.format) !== 'RGBA8') {
console.log('Format is', ri.format, '— this script requires RGBA8. Open an RGBA8 image and run again.');
} else {
const bitmap = ri.createCompatibleBitmap(true);
const reader = PixelReaderRGBA8.create(bitmap);
// Per-channel histograms (256 buckets each)
const histR = new Array(256).fill(0);
const histG = new Array(256).fill(0);
const histB = new Array(256).fill(0);
const histL = new Array(256).fill(0); // luminance
// Per-channel min/max
let minR = 255, maxR = 0;
let minG = 255, maxG = 0;
let minB = 255, maxB = 0;
// Totals for averages
let totalR = 0, totalG = 0, totalB = 0;
// Tonal zone counts
let shadows = 0, midtones = 0, highlights = 0;
// Clipping counts (pixels at 0 or 255)
let clippedShadow = 0, clippedHighlight = 0;
let opaqueCount = 0;
console.log(`Analysing ${w}×${h} pixels...`);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const px = reader.readPixel(x, y);
// Skip fully transparent pixels — they don't contribute to visible tones
if (px.alpha === 0) continue;
opaqueCount++;
// Accumulate channel histograms
histR[px.r]++;
histG[px.g]++;
histB[px.b]++;
// Luminance (perceptual weighting)
const L = Math.round(0.299 * px.r + 0.587 * px.g + 0.114 * px.b);
histL[L]++;
// Per-channel min/max
if (px.r < minR) minR = px.r;
if (px.r > maxR) maxR = px.r;
if (px.g < minG) minG = px.g;
if (px.g > maxG) maxG = px.g;
if (px.b < minB) minB = px.b;
if (px.b > maxB) maxB = px.b;
// Totals
totalR += px.r;
totalG += px.g;
totalB += px.b;
// Tonal zones: shadows 0–84, midtones 85–169, highlights 170–255
if (L <= 84) shadows++;
else if (L <= 169) midtones++;
else highlights++;
// Clipping
if (L === 0) clippedShadow++;
if (L === 255) clippedHighlight++;
}
}
if (opaqueCount === 0) {
console.log('No opaque pixels found.');
} else {
const avgR = Math.round(totalR / opaqueCount);
const avgG = Math.round(totalG / opaqueCount);
const avgB = Math.round(totalB / opaqueCount);
const avgL = Math.round(0.299 * avgR + 0.587 * avgG + 0.114 * avgB);
console.log('');
console.log(`=== ${name} — ${w}×${h} (${opaqueCount} opaque pixels) ===`);
console.log('');
console.log('── Channel ranges ──────────────────────────');
console.log(` Red: min ${String(minR).padStart(3)} max ${String(maxR).padStart(3)} avg ${String(avgR).padStart(3)}`);
console.log(` Green: min ${String(minG).padStart(3)} max ${String(maxG).padStart(3)} avg ${String(avgG).padStart(3)}`);
console.log(` Blue: min ${String(minB).padStart(3)} max ${String(maxB).padStart(3)} avg ${String(avgB).padStart(3)}`);
console.log(` Luma: avg ${String(avgL).padStart(3)}`);
console.log('');
console.log('── Tonal distribution ──────────────────────');
const pct = (n) => ((n / opaqueCount) * 100).toFixed(1).padStart(5) + '%';
console.log(` Shadows (0–84): ${pct(shadows)} (${shadows} px)`);
console.log(` Midtones (85–169): ${pct(midtones)} (${midtones} px)`);
console.log(` Highlights(170–255): ${pct(highlights)} (${highlights} px)`);
console.log('');
console.log('── Clipping ────────────────────────────────');
const clipPctS = ((clippedShadow / opaqueCount) * 100).toFixed(2);
const clipPctH = ((clippedHighlight / opaqueCount) * 100).toFixed(2);
console.log(` Shadow clip (L=0): ${clipPctS}% (${clippedShadow} px)`);
console.log(` Highlight clip (L=255): ${clipPctH}% (${clippedHighlight} px)`);
if (parseFloat(clipPctS) > 1) console.log(' ⚠ Shadow clipping exceeds 1%');
if (parseFloat(clipPctH) > 1) console.log(' ⚠ Highlight clipping exceeds 1%');
// Luminance histogram bar chart — 32 buckets of 8 values each
console.log('');
console.log('── Luminance histogram (0=dark → 255=bright) ──');
const bucketCount = 32;
const buckets = new Array(bucketCount).fill(0);
for (let i = 0; i < 256; i++) {
buckets[Math.floor(i / (256 / bucketCount))] += histL[i];
}
const maxBucket = Math.max(...buckets);
const barWidth = 30;
for (let i = 0; i < bucketCount; i++) {
const low = Math.round(i * 256 / bucketCount);
const filled = Math.round((buckets[i] / maxBucket) * barWidth);
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
console.log(` ${String(low).padStart(3)} ${bar}`);
}
}
}
}
}
15f-posterise.js
'use strict';
// Example 14f — Posterise
// Lesson 14: Pixel Processing
//
// Reduces each colour channel to N discrete levels, creating a flat, graphic
// poster-like effect. The level count is configurable via a dialog (2–16).
//
// Posterisation works by quantising each channel value to the nearest step
// in a set of N evenly-spaced levels across 0–255. With 2 levels each channel
// is either 0 or 255. With 4 levels the steps are 0, 85, 170, 255.
//
// This effect cannot be expressed as a simple adjustment layer — it requires
// per-pixel arithmetic, which is why it belongs in a pixel script.
//
// Open the image as a document (File > Open), select the layer, and run.
const { Document } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const { Dialog, DialogResult } = require('/dialog.js');
const { PixelReaderWriterRGBA8 } = require('/pixelaccessor.js');
// ── Dialog ──────────────────────────────────────────────────────────────────
// addComboBox is used for level selection — the SDK dialog has no slider control.
const dlg = Dialog.create('Posterise');
const col = dlg.addColumn();
const grp = col.addGroup('Settings');
const LEVELS = ['2', '3', '4', '5', '6', '8'];
const combo = grp.addComboBox('Levels', LEVELS, 2); // default index 2 = 4 levels
const result = dlg.runModal();
if (result != DialogResult.Ok) {
console.log('Cancelled.');
} else {
const levels = parseInt(LEVELS[combo.selectedIndex], 10); // read selectedIndex, not .value
// ── Pixel processing ───────────────────────────────────────────────────────
const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const node = doc.selection.nodes.filter(n => n.isRasterNode || n.isImageNode).first;
if (!node) {
console.log('Select a raster or image node and run again.');
} else {
const ri = node.rasterInterface;
const w = ri.width;
const h = ri.height;
if (String(ri.format) !== 'RGBA8') {
console.log('Format is', ri.format, '— this script requires RGBA8. Open an RGBA8 image and run again.');
} else {
const bitmap = ri.createCompatibleBitmap(true);
const rw = PixelReaderWriterRGBA8.create(bitmap);
console.log(`Posterising ${w}×${h} to ${levels} levels...`);
// Pre-compute a lookup table: for each input value 0–255, what is the
// nearest quantised output? This avoids repeating the arithmetic per pixel.
//
// For N levels, the step size is 255 / (N-1).
// The quantised value is: round(input / step) * step, clamped to 0–255.
const lut = new Uint8Array(256);
const step = 255 / (levels - 1);
for (let i = 0; i < 256; i++) {
lut[i] = Math.min(255, Math.round(Math.round(i / step) * step));
}
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const px = rw.readPixel(x, y);
rw.writePixel(x, y, {
r: lut[px.r],
g: lut[px.g],
b: lut[px.b],
alpha: px.alpha // preserve transparency
});
}
}
doc.executeCommand(DocumentCommand.createReplaceBitmap(doc.selection, bitmap));
console.log(`Done — posterised to ${levels} levels.`);
}
}
}
}