A 3 MB photograph becomes a 200 KB file. Where does the data go? JPEG's pipeline is eight distinct mathematical steps — each one exploiting a different property of human vision. Here they all are, in runnable JavaScript.
A pixel is three 8-bit numbers — red, green, blue — so 1 byte each. A 12-megapixel photo at 3:2 aspect is 4000×3000 pixels. That's 36 million bytes just for the raw data, before any header, metadata, or framing.
Human perception has two important asymmetries that JPEG exploits: the eye is far more sensitive to brightness changes than colour changes, and far more sensitive to low spatial frequencies (gradual changes) than high ones (fine detail). JPEG throws away exactly what you can't see.
// How much data does an uncompressed image contain? function rawSize(width, height, channels = 3, bitsPerChannel = 8) { const bytes = width * height * channels * (bitsPerChannel / 8); return { bytes, kb: bytes/1024, mb: bytes/1024/1024 }; } const cameras = [ { name: "4K frame", w: 3840, h: 2160 }, { name: "12MP phone", w: 4000, h: 3000 }, { name: "48MP phone", w: 8064, h: 6048 }, { name: "1080p video frame", w: 1920, h: 1080 }, ]; for (const cam of cameras) { const r = rawSize(cam.w, cam.h); console.log(`${cam.name.padEnd(20)} ${cam.w}×${cam.h}`); console.log(` Raw RGB: ${r.mb.toFixed(1)} MB`); console.log(` Typical JPEG Q=80: ~${(r.mb/15).toFixed(2)} MB (${Math.round(15)}:1 compression)`); console.log(` Typical JPEG Q=50: ~${(r.mb/25).toFixed(2)} MB (${Math.round(25)}:1 compression)`); } // 30fps video without compression const videoRaw = rawSize(1920, 1080); console.log(`\n1080p video @ 30fps:`); console.log(` ${(videoRaw.mb * 30).toFixed(0)} MB/s uncompressed`); console.log(` ${(videoRaw.mb * 30 * 3600 / 1024).toFixed(0)} GB/hour`);
RGB stores three equally-weighted colour channels. But your eye has ~120 million rod cells (brightness-sensitive) and only ~6 million cone cells (colour-sensitive), and the cones are densest at the fovea. JPEG exploits this by converting to YCbCr: one luma channel Y (brightness) and two chroma channels Cb and Cr (blue-difference and red-difference colour).
This separation is the precondition for chroma subsampling in the next step — you can discard colour information you'll barely notice, while preserving every luma bit.
The three output channels are computed as weighted sums of R, G, and B. The weights come from the ITU-R BT.601 standard and reflect how the human eye perceives colour.
Y (luma) is a weighted average of all three channels, with green weighted most heavily because the eye has the most green-sensitive cones:
Cb (blue-difference chroma) measures how much bluer the pixel is relative to its luma. It's centred at 128 (neutral grey maps to exactly 128):
Cr (red-difference chroma) measures how much redder the pixel is relative to its luma, also centred at 128:
Three things to notice: the coefficients in each row sum to zero (before the +128 offset), which means a neutral grey always maps to Cb=128, Cr=128 regardless of its brightness. The +128 offset shifts the range from [−128, 127] to [0, 255] so it fits in a byte. And the Y coefficients sum to 1.0 (0.299 + 0.587 + 0.114 = 1.0), so white (255,255,255) maps to Y=255.
// RGB ↔ YCbCr conversion (ITU-R BT.601 coefficients — used by JPEG) function rgbToYCbCr(r, g, b) { const Y = 0.299 * r + 0.587 * g + 0.114 * b; const Cb = -0.16875 * r - 0.33126 * g + 0.5 * b + 128; const Cr = 0.5 * r - 0.41869 * g - 0.08131 * b + 128; return [Math.round(Y), Math.round(Cb), Math.round(Cr)]; } function yCbCrToRGB(Y, Cb, Cr) { const r = Y + 1.40200 * (Cr - 128); const g = Y - 0.34414 * (Cb - 128) - 0.71414 * (Cr - 128); const b = Y + 1.77200 * (Cb - 128); const clamp = v => Math.max(0, Math.min(255, Math.round(v))); return [clamp(r), clamp(g), clamp(b)]; } // Test roundtrip on a few colours const colours = [ [255, 0, 0 ], // red [0, 255, 0 ], // green [0, 0, 255], // blue [180, 100, 40 ], // warm orange [200, 200, 200], // grey ]; for (const [r,g,b] of colours) { const [Y,Cb,Cr] = rgbToYCbCr(r,g,b); const [r2,g2,b2] = yCbCrToRGB(Y,Cb,Cr); console.log(`RGB(${String(r).padStart(3)},${String(g).padStart(3)},${String(b).padStart(3)})` + ` → Y=${String(Y).padStart(3)} Cb=${String(Cb).padStart(3)} Cr=${String(Cr).padStart(3)}` + ` → RGB(${r2},${g2},${b2}) ✓`); } // Key insight: Y carries all the perceived brightness // Two pixels can have the same Y but look very different in colour const [Yr,Cbr,Crr] = rgbToYCbCr(255,0,0); const [Yg,Cbg,Crg] = rgbToYCbCr(0,255,0); const [Yb,Cbb,Crb] = rgbToYCbCr(0,0,255); console.log(`\nLuma (Y) of pure colours:`); console.log(` Red: Y=${Yr} (low — red looks dark in B&W)`); console.log(` Green: Y=${Yg} (high — green appears brightest)`); console.log(` Blue: Y=${Yb} (medium-low)`);
Because the eye is far less sensitive to colour detail than to brightness detail, JPEG discards 75% of the colour data before any other compression takes place. In the most common mode 4:2:0, every 2×2 block of pixels shares a single Cb value and a single Cr value — but each pixel keeps its own Y value.
This step alone reduces data to 50% of the original (from 3 bytes/pixel to 1.5 bytes/pixel) with virtually invisible quality loss on photographic content.
// Chroma subsampling: 4:2:0 // Discard 3 of every 4 chroma samples — nearly invisible to human vision function subsample420(channel, width, height) { // Average each 2×2 block into one sample const outW = width >> 1; // half width const outH = height >> 1; // half height const out = new Float32Array(outW * outH); for (let y = 0; y < outH; y++) { for (let x = 0; x < outW; x++) { out[y*outW+x] = ( channel[(y*2 ) * width + (x*2 )] + channel[(y*2 ) * width + (x*2+1)] + channel[(y*2+1) * width + (x*2 )] + channel[(y*2+1) * width + (x*2+1)] ) / 4; } } return out; } function upsample420(subsampled, outW, outH) { // Nearest-neighbour upsample (JPEG decoder does bilinear in practice) const inW = outW >> 1; const out = new Float32Array(outW * outH); for (let y = 0; y < outH; y++) for (let x = 0; x < outW; x++) out[y*outW+x] = subsampled[(y>>1)*inW + (x>>1)]; return out; } // Simulate with a 4×4 grid of Cb values const W = 4, H = 4; const cbOriginal = Float32Array.from([ 128, 130, 100, 100, 128, 130, 100, 100, 140, 140, 110, 110, 140, 140, 110, 110, ]); const cbDown = subsample420(cbOriginal, W, H); const cbUp = upsample420(cbDown, W, H); console.log("Original Cb (4×4):"); for (let r = 0; r < H; r++) console.log(" ", [...cbOriginal.slice(r*W,(r+1)*W)].join(" ")); console.log("\nSubsampled 4:2:0 (2×2):"); for (let r = 0; r < H/2; r++) console.log(" ", [...cbDown.slice(r*W/2,(r+1)*W/2)].map(v=>v.toFixed(1)).join(" ")); console.log("\nUpsampled back to 4×4 (reconstruction):"); for (let r = 0; r < H; r++) console.log(" ", [...cbUp.slice(r*W,(r+1)*W)].map(v=>v.toFixed(1)).join(" ")); const origBytes = W * H * 3; // Y + Cb + Cr const subBytes = W * H + 2 * (W/2) * (H/2); // Y full, Cb/Cr halved each dim console.log(`\nData: ${origBytes} bytes → ${subBytes} bytes (${(subBytes/origBytes*100).toFixed(0)}% retained)`);
Each channel is divided into non-overlapping 8×8 blocks (64 pixels). Each block is transformed by the Discrete Cosine Transform (DCT), which converts the block from the spatial domain (pixel values) into the frequency domain (how much of each spatial frequency is present).
Intuitively, the spatial domain is just the block as you would normally look at it: this pixel is bright, that pixel is darker, this corner changes quickly. The frequency domain asks a different question: how much of this block is smooth overall, how much is a gentle left-to-right fade, how much is a fine checkerboard-like texture? Instead of describing the block pixel by pixel, JPEG describes it as a mixture of simple visual patterns, from broad smooth shading to tiny rapid changes. That is useful because photographs usually contain much more low-frequency structure than high-frequency detail, so JPEG can keep the broad patterns and coarsen the tiny ones.
The output is also an 8×8 grid of 64 numbers called DCT coefficients. The top-left coefficient (DC) represents the average brightness of the block. The others (AC) represent increasingly fine detail from left-to-right and top-to-bottom.
// 2D DCT-II — the exact transform used in JPEG // Input: 8×8 block of pixel values (shifted: 0→255 becomes -128→127) // Output: 8×8 block of frequency coefficients const N = 8; const C = u => u === 0 ? 1/Math.sqrt(2) : 1; // normalisation factor function dct2d(block) { // block is a flat Float32Array of 64 values, row-major const out = new Float32Array(64); const scale = 2 / N; for (let v = 0; v < N; v++) { for (let u = 0; u < N; u++) { let sum = 0; for (let y = 0; y < N; y++) { for (let x = 0; x < N; x++) { sum += block[y*N+x] * Math.cos(((2*x+1)*u*Math.PI) / (2*N)) * Math.cos(((2*y+1)*v*Math.PI) / (2*N)); } } out[v*N+u] = 0.25 * C(u) * C(v) * sum; } } return out; } function idct2d(coeffs) { const out = new Float32Array(64); for (let y = 0; y < N; y++) { for (let x = 0; x < N; x++) { let sum = 0; for (let v = 0; v < N; v++) for (let u = 0; u < N; u++) sum += C(u) * C(v) * coeffs[v*N+u] * Math.cos(((2*x+1)*u*Math.PI) / (2*N)) * Math.cos(((2*y+1)*v*Math.PI) / (2*N)); out[y*N+x] = 0.25 * sum; } } return out; } // Test: transform a simple block and check roundtrip const testBlock = Float32Array.from([ 52, 55, 61, 66, 70, 61, 64, 73, 63, 59, 55, 90, 109,85, 69, 72, 62, 59, 68, 113,144,104,66, 73, 63, 58, 71, 122,154,106,70, 69, 67, 61, 68, 104,126,88, 68, 70, 79, 65, 60, 70, 77, 68, 58, 75, 85, 71, 64, 59, 55, 61, 65, 83, 87, 79, 69, 68, 65, 76, 78, 94, ].map(v => v - 128)); // level-shift: subtract 128 const coeffs = dct2d(testBlock); const restored = idct2d(coeffs); console.log("DCT coefficients (first row):"); console.log(" ", [...coeffs.slice(0,8)].map(v=>v.toFixed(1)).join(" ")); console.log(`\nDC coefficient (block average): ${coeffs[0].toFixed(2)}`); console.log(`Expected (mean - 128 scaled): ~${((testBlock.reduce((a,b)=>a+b)/64)*2).toFixed(1)}`); // Verify roundtrip: IDCT(DCT(x)) should recover x const maxErr = Math.max(...testBlock.map((v,i) => Math.abs(v - restored[i]))); console.log(`\nRoundtrip max error: ${maxErr.toFixed(6)} (should be ~0)`);
The DCT represents each 8×8 block as a weighted sum of 64 basis patterns. Each basis pattern is a specific spatial frequency — from the DC average (pattern 0,0) to a rapidly-oscillating checkerboard (pattern 7,7). The DCT coefficient for each pattern is the weight.
Click any basis pattern below to see it, and see what its coefficient is in the example block. The fact that natural images have most energy in low-frequency patterns is exactly why JPEG achieves high compression.
Each DCT coefficient is divided by a number from the quantisation table and rounded to the nearest integer. This is the only irreversible step in JPEG — the one where information is permanently discarded.
The quantisation table is just an 8×8 grid of divisor values, one for each position in the 8×8 DCT coefficient block. JPEG is effectively saying: “for this low-frequency coefficient, divide by a small number and keep it fairly accurately; for this high-frequency coefficient, divide by a larger number and round more aggressively.”
The quantisation table has larger values for high-frequency coefficients (bottom-right of the grid), discarding fine detail aggressively, and small values for low-frequency coefficients (top-left), preserving smooth gradients. The JPEG quality setting scales this table: Q=100 means divide by ~1 (lossless-ish); Q=1 means divide by ~100 (heavily lossy).
// Quantisation — the only lossy step in JPEG // JPEG standard luminance quantisation table (quality factor 50) const LUMA_Q50 = [ 16, 11, 10, 16, 24, 40, 51, 61, 12, 12, 14, 19, 26, 58, 60, 55, 14, 13, 16, 24, 40, 57, 69, 56, 14, 17, 22, 29, 51, 87, 80, 62, 18, 22, 37, 56, 68, 109,103,77, 24, 35, 55, 64, 81, 104,113,92, 49, 64, 78, 87, 103,121,120,101, 72, 92, 95, 98, 112,100,103,99, ]; function scaleQTable(baseTable, quality) { // IJG quality scaling formula const scale = quality < 50 ? 5000 / quality : 200 - 2 * quality; return baseTable.map(v => Math.max(1, Math.min(255, Math.floor((v * scale + 50) / 100)))); } function quantise(coeffs, qTable) { return coeffs.map((c, i) => Math.round(c / qTable[i])); } function dequantise(quantised, qTable) { return quantised.map((c, i) => c * qTable[i]); } // Compare different quality settings on the example coefficients const exampleCoeffs = Float32Array.from([ -415, -30, -61, 27, 56, -20, -2, 0, 4, -22, -40, 26, 14, -6, -3, 1, -47, 13, 57, -26, -23, 3, 5, 0, -5, 7, -4, -1, 5, -1, -1, 0, 1, -2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ]); for (const Q of [90, 75, 50, 25]) { const qTable = scaleQTable(LUMA_Q50, Q); const quant = quantise(exampleCoeffs, qTable); const zeros = quant.filter(v => v === 0).length; const dequant= dequantise(quant, qTable); const maxErr = Math.max(...exampleCoeffs.map((v,i) => Math.abs(v - dequant[i]))); console.log(`Q=${Q}: ${zeros}/64 zeros, max error=${maxErr.toFixed(0)}`); if (Q === 75) { console.log(" First row quantised:", [...quant.slice(0,8)].join(" ")); } }
After quantisation, the 8×8 grid of coefficients needs to be serialised into a 1D sequence for entropy coding. JPEG uses a zigzag order — starting from the DC coefficient (top-left), spiralling outward — so that low-frequency coefficients come first and the long string of zeros at the end (the discarded high-frequency detail) comes last.
This sequence of integers is then run-length encoded: long runs of zeros are replaced by (count, next-nonzero) pairs. A special EOB (end-of-block) symbol marks where the zeros start and never stop.
// Zigzag order: traversal pattern for an 8×8 grid // Ensures DC first, then low→high frequency AC coefficients const ZIGZAG = [ 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, ]; function zigzagSerialise(block) { return ZIGZAG.map(i => block[i]); } // Run-length encode the AC coefficients // DC is handled separately (delta-coded across blocks) // Format: (zeroes_before, value) pairs, then EOB = (0,0) function rleAC(zigzagged) { const result = []; let zeroRun = 0; for (let i = 1; i < zigzagged.length; i++) { // skip DC at index 0 if (zigzagged[i] === 0) { zeroRun++; } else { // ZRL (zero run length > 15): encode as (15, 0) repeated while (zeroRun > 15) { result.push([15, 0]); zeroRun -= 16; } result.push([zeroRun, zigzagged[i]]); zeroRun = 0; } } result.push([0, 0]); // EOB — end of block marker return result; } // Example: quantised block with many zeros const quantised = [ -26, -3, -6, 2, 2, -1, 0, 0, 0, -2, -4, 1, 1, 0, 0, 0, -3, 1, 5, -1, -1, 0, 0, 0, -3, 1, 2, -1, 0, 0, 0, 0, 1, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ].flat(); const zzSeq = zigzagSerialise(quantised); const rle = rleAC(zzSeq); console.log("DC coefficient:", zzSeq[0], "(encoded separately, delta from prev block)"); console.log("AC zigzag sequence:", zzSeq.slice(1).join(" ")); console.log("\nRLE pairs (zeros_before, value):"); rle.forEach(([z,v]) => { if (z===0 && v===0) console.log(" EOB — end of block"); else console.log(` (${z}, ${v})`); }); console.log(`\n64 values → ${rle.length} RLE pairs = ${(rle.length/64*100).toFixed(0)}% of raw size`);
The final step encodes the RLE pairs using Huffman coding, a lossless compression technique that assigns shorter bit patterns to more frequent symbols. In a typical JPEG block, small coefficients like (0, 1) or (0, -1) appear far more often than large ones — Huffman exploits this imbalance.
JPEG precomputes Huffman tables for typical image data (the standard tables are in the JPEG spec). The encoder scans the RLE pairs and emits variable-length bit strings. The decoder reads bits one at a time, walking a Huffman tree until it reaches a leaf.
// Huffman coding — lossless entropy compression // Frequent symbols get short codes; rare ones get long codes class HuffNode { constructor(sym, freq, left=null, right=null) { this.sym=sym; this.freq=freq; this.left=left; this.right=right; } } function buildHuffmanTree(freqMap) { let heap = [...freqMap].map(([s,f]) => new HuffNode(s,f)); heap.sort((a,b) => a.freq - b.freq); while (heap.length > 1) { const a = heap.shift(), b = heap.shift(); const merged = new HuffNode(null, a.freq+b.freq, a, b); const ins = heap.findIndex(n => n.freq >= merged.freq); heap.splice(ins === -1 ? heap.length : ins, 0, merged); } return heap[0]; } function buildCodeTable(root) { const table = new Map(); function walk(node, code) { if (node.sym !== null) { table.set(node.sym, code); return; } walk(node.left, code + "0"); walk(node.right, code + "1"); } walk(root, ""); return table; } function encode(symbols, table) { return symbols.map(s => table.get(s) ?? "?").join(""); } function decode(bits, root) { const result = []; let node = root; for (const bit of bits) { node = bit === "0" ? node.left : node.right; if (node.sym !== null) { result.push(node.sym); node = root; } } return result; } // JPEG-like symbol frequencies from a typical luminance block const freqs = new Map([ ["EOB", 50], ["(0,1)", 30], ["(0,-1)", 25], ["(0,2)", 15], ["(1,1)", 12], ["(0,-2)", 10], ["(0,3)", 6], ["(2,1)", 4], ["(0,-3)", 3], ]); const tree = buildHuffmanTree(freqs); const codes = buildCodeTable(tree); console.log("Huffman code table:"); [...codes.entries()].sort((a,b) => a[1].length - b[1].length) .forEach(([sym, code]) => { const freq = freqs.get(sym); console.log(` ${sym.padEnd(10)} freq=${String(freq).padStart(3)} → ${code.padEnd(10)} (${code.length} bits)`); }); // Encode a sample sequence const seq = ["(0,1)", "(0,-1)", "(0,2)", "EOB"]; const bits = encode(seq, codes); const back = decode(bits, tree); console.log(`\nEncoded [${seq.join(",")}]:`); console.log(` Bits: ${bits} (${bits.length} bits)`); console.log(` Fixed 8-bit encoding: ${seq.length * 8} bits`); console.log(` Saving: ${seq.length*8 - bits.length} bits (${((1-bits.length/(seq.length*8))*100).toFixed(0)}%)`); console.log(` Decoded back: [${back.join(",")}] ✓`);
Putting all eight steps together: take an 8×8 block of pixel values through the complete JPEG pipeline and back. This is what happens to every block in every channel of every JPEG file ever saved.
// Complete JPEG pipeline: pixels → DCT → quantise → zigzag → RLE // Then the reverse: RLE decode → dezigzag → dequantise → IDCT → pixels const N = 8; const C = u => u === 0 ? 1/Math.sqrt(2) : 1; const dct2d = block => { const out = new Float32Array(64); for (let v=0;v<N;v++) for (let u=0;u<N;u++) { let s=0; for (let y=0;y<N;y++) for (let x=0;x<N;x++) s+=block[y*N+x]*Math.cos((2*x+1)*u*Math.PI/(2*N))*Math.cos((2*y+1)*v*Math.PI/(2*N)); out[v*N+u]=0.25*C(u)*C(v)*s; } return out; }; const idct2d = coeffs => { const out = new Float32Array(64); for (let y=0;y<N;y++) for (let x=0;x<N;x++) { let s=0; for (let v=0;v<N;v++) for (let u=0;u<N;u++) s+=C(u)*C(v)*coeffs[v*N+u]*Math.cos((2*x+1)*u*Math.PI/(2*N))*Math.cos((2*y+1)*v*Math.PI/(2*N)); out[y*N+x]=0.25*s; } return out; }; const LUMA_Q50 = [ 16,11,10,16,24,40,51,61, 12,12,14,19,26,58,60,55, 14,13,16,24,40,57,69,56, 14,17,22,29,51,87,80,62, 18,22,37,56,68,109,103,77, 24,35,55,64,81,104,113,92, 49,64,78,87,103,121,120,101, 72,92,95,98,112,100,103,99, ]; const ZIGZAG = [ 0, 1, 8,16, 9, 2, 3,10, 17,24,32,25,18,11, 4, 5, 12,19,26,33,40,48,41,34, 27,20,13, 6, 7,14,21,28, 35,42,49,56,57,50,43,36, 29,22,15,23,30,37,44,51, 58,59,52,45,38,31,39,46, 53,60,61,54,47,55,62,63, ]; function scaleQ(q) { const s = q<50 ? 5000/q : 200-2*q; return LUMA_Q50.map(v => Math.max(1, Math.min(255, Math.floor((v*s+50)/100)))); } function encodeBlock(pixels, quality) { const qTable = scaleQ(quality); const shifted = pixels.map(v => v - 128); const coeffs = dct2d(Float32Array.from(shifted)); const quant = coeffs.map((c,i) => Math.round(c / qTable[i])); const zigzag = ZIGZAG.map(i => quant[i]); const zeros = quant.filter(v => v===0).length; return { quant, zigzag, zeros, qTable }; } function decodeBlock(quant, qTable) { const dequant = quant.map((c,i) => c * qTable[i]); const spatial = idct2d(Float32Array.from(dequant)); return spatial.map(v => Math.max(0, Math.min(255, Math.round(v + 128)))); } // A real 8×8 luminance block from a photograph (the classic JPEG test block) const original = [ 52, 55, 61, 66, 70, 61, 64, 73, 63, 59, 55, 90,109, 85, 69, 72, 62, 59, 68,113,144,104, 66, 73, 63, 58, 71,122,154,106, 70, 69, 67, 61, 68,104,126, 88, 68, 70, 79, 65, 60, 70, 77, 68, 58, 75, 85, 71, 64, 59, 55, 61, 65, 83, 87, 79, 69, 68, 65, 76, 78, 94, ]; for (const Q of [95, 75, 50, 25]) { const enc = encodeBlock(original, Q); const restored = decodeBlock(enc.quant, enc.qTable); const maxErr = Math.max(...original.map((v,i) => Math.abs(v - restored[i]))); const mse = original.reduce((s,v,i) => s+(v-restored[i])**2,0) / 64; const psnr = 10 * Math.log10(255*255 / mse); console.log(`Q=${String(Q).padStart(3)}: ${enc.zeros}/64 zeros, maxErr=${String(maxErr).padStart(3)}, PSNR=${psnr.toFixed(1)}dB`); } console.log("\nRestored block at Q=75:"); const enc75 = encodeBlock(original, 75); const res75 = decodeBlock(enc75.quant, enc75.qTable); for (let r=0;r<8;r++) console.log(" ", [...res75.slice(r*8,(r+1)*8)].join(" "));
The JPEG quality setting (1–100) controls a single scalar that scales the quantisation table. At Q=100 the quantisation step is ~1 so almost no information is discarded. At Q=1 it's ~200 — every coefficient is rounded to zero or ±1. Real images are typically saved at Q=75–85 for web use.