TypeScript: a grayscale image class with threshold and flip

Wrap a flat pixel buffer in a class that checks bounds and returns new images from its operations.

TypeScript
class GrayImage {
  readonly width: number;
  readonly height: number;
  private data: Uint8Array;

  constructor(width: number, height: number, data?: Uint8Array) {
    this.width = width;
    this.height = height;
    this.data = data ?? new Uint8Array(width * height);
  }

  static fromRows(rows: number[][]): GrayImage {
    const image = new GrayImage(rows[0].length, rows.length);
    rows.forEach((row, y) => row.forEach((value, x) => image.set(x, y, value)));
    return image;
  }

  get(x: number, y: number): number {
    if (x < 0 || y < 0 || x >= this.width || y >= this.height) {
      throw new RangeError("pixel (" + x + "," + y + ") is outside the image");
    }
    return this.data[y * this.width + x];
  }

  set(x: number, y: number, value: number): void {
    this.get(x, y);
    this.data[y * this.width + x] = value;
  }

  threshold(cutoff: number): GrayImage {
    return new GrayImage(this.width, this.height, this.data.map((v) => (v > cutoff ? 255 : 0)));
  }

  flipHorizontal(): GrayImage {
    const out = new GrayImage(this.width, this.height);
    for (let y = 0; y < this.height; y++) {
      for (let x = 0; x < this.width; x++) {
        out.set(this.width - 1 - x, y, this.get(x, y));
      }
    }
    return out;
  }

  toText(): string {
    const lines: string[] = [];
    for (let y = 0; y < this.height; y++) {
      let line = "";
      for (let x = 0; x < this.width; x++) {
        line += this.get(x, y) > 127 ? "#" : ".";
      }
      lines.push(line);
    }
    return lines.join("\n");
  }
}

const image = GrayImage.fromRows([
  [10, 200, 30, 220],
  [240, 20, 250, 40],
  [60, 180, 70, 190],
]);
console.log(image.threshold(128).toText());
console.log("--");
console.log(image.threshold(128).flipHorizontal().toText());
try {
  image.get(4, 0);
} catch (error) {
  console.log((error as Error).message);
}
Output
.#.#
#.#.
.#.#
--
#.#.
.#.#
#.#.
pixel (4,0) is outside the image

Explanation

A grayscale image is a grid of brightness values. Storing it in one flat Uint8Array of width × height bytes is compact and fast, and the pixel at column x and row y lives at index y × width + x. A class hides this index arithmetic and checks that coordinates are inside the image.

Thresholding turns each pixel into 255 or 0 depending on whether it is brighter than a cutoff, giving a binary image. A horizontal flip mirrors each row. Both return a new image and leave the original unchanged, which makes the operations easy to reason about and to test. The toText method draws bright pixels as # and dark ones as a dot.

Explore the dry run
Guided walkthroughReady
1class GrayImage {
2 readonly width: number;
3 readonly height: number;
4 private data: Uint8Array;
5
6 constructor(width: number, height: number, data?: Uint8Array) {
7 this.width = width;
8 this.height = height;
9 this.data = data ?? new Uint8Array(width * height);
10 }
11
12 static fromRows(rows: number[][]): GrayImage {
13 const image = new GrayImage(rows[0].length, rows.length);
14 rows.forEach((row, y) => row.forEach((value, x) => image.set(x, y, value)));
15 return image;
16 }
17
18 get(x: number, y: number): number {
19 if (x < 0 || y < 0 || x >= this.width || y >= this.height) {
20 throw new RangeError("pixel (" + x + "," + y + ") is outside the image");
21 }
22 return this.data[y * this.width + x];
23 }
24
25 set(x: number, y: number, value: number): void {
26 this.get(x, y);
27 this.data[y * this.width + x] = value;
28 }
29
30 threshold(cutoff: number): GrayImage {
31 return new GrayImage(this.width, this.height, this.data.map((v) => (v > cutoff ? 255 : 0)));
32 }
33
34 flipHorizontal(): GrayImage {
35 const out = new GrayImage(this.width, this.height);
36 for (let y = 0; y < this.height; y++) {
37 for (let x = 0; x < this.width; x++) {
38 out.set(this.width - 1 - x, y, this.get(x, y));
39 }
40 }
41 return out;
42 }
43
44 toText(): string {
45 const lines: string[] = [];
46 for (let y = 0; y < this.height; y++) {
47 let line = "";
48 for (let x = 0; x < this.width; x++) {
49 line += this.get(x, y) > 127 ? "#" : ".";
50 }
51 lines.push(line);
52 }
53 return lines.join("\n");
54 }
55}
56
57const image = GrayImage.fromRows([
58 [10, 200, 30, 220],
59 [240, 20, 250, 40],
60 [60, 180, 70, 190],
61]);
62console.log(image.threshold(128).toText());
63console.log("--");
64console.log(image.threshold(128).flipHorizontal().toText());
65try {
66 image.get(4, 0);
67} catch (error) {
68 console.log((error as Error).message);
69}

Follow the example

Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.

View the complete dry-run table
Prepared trace of the published program. Some rows group repeated iterations.
Step / lineWhat happensState after stepOutput so far
Row-major layout: the pixel at (x, y) is at index y × width + x.—
—
Out-of-range coordinates are rejected instead of reading the wrong row.—
—
Each pixel above the cutoff becomes 255; the others become 0. The original image is untouched.—
—
Bright pixels are drawn as #.—
.#.#
#.#.
.#.#
Each row is mirrored left to right.—
.#.#
#.#.
.#.#
--
#.#.
.#.#
#.#.
x = 4 is outside a 4-pixel-wide image (valid x is 0 to 3).—
.#.#
#.#.
.#.#
--
#.#.
.#.#
#.#.
pixel (4,0) is outside the image

Cost and common mistakes

get and set are O(1); threshold, flip, and toText are O(width × height).

  • Without the bounds check, get(4, 0) would silently return the first pixel of the next row.
  • Uint8Array stores only 0 to 255. Larger values wrap around, so clamp them before storing.
  • Methods that return new images use extra memory. For large images, in-place operations or views are common.

Try it yourself

What does flipHorizontal().flipHorizontal() give?

Reveal answer and reasoning

The original image, because mirroring twice restores every row.

Further reading