Skip to content

Commit c40a886

Browse files
Timna BrownTimna Brown
authored andcommitted
Make fax mode a true low-res 1-bit pipeline with transmission artifacts
1 parent 185e272 commit c40a886

2 files changed

Lines changed: 77 additions & 23 deletions

File tree

app.js

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const presetSettings = {
2929
clean: { brightness: 16, contrast: 114, grain: 1, vignette: 2, threshold: 188, shadowBoost: 0.14, binaryMix: 0.58, lineNoise: 0.014, tonerNoise: 0.006 },
3030
classic: { brightness: 8, contrast: 132, grain: 6, vignette: 5, threshold: 166, shadowBoost: 0.28, binaryMix: 0.74, lineNoise: 0.028, tonerNoise: 0.012 },
3131
"high-contrast": { brightness: 3, contrast: 152, grain: 3, vignette: 1, threshold: 150, shadowBoost: 0.4, binaryMix: 0.88, lineNoise: 0.018, tonerNoise: 0.009 },
32-
fax: { brightness: 0, contrast: 168, grain: 7, vignette: 1, threshold: 142, shadowBoost: 0.46, binaryMix: 0.96, lineNoise: 0.045, tonerNoise: 0.022 },
32+
fax: { brightness: 0, contrast: 172, grain: 7, vignette: 1, threshold: 142, shadowBoost: 0.46, binaryMix: 1, lineNoise: 0.045, tonerNoise: 0.022, processingScale: 0.42, pureMono: true, streakStrength: 0.055, dropoutChance: 0.0024 },
3333
};
3434

3535
const presetDescriptions = {
@@ -1230,20 +1230,52 @@ function loadImage(file) {
12301230
});
12311231
}
12321232

1233-
function applyScanEffect(sourceImage, controls, dimensions = { width: sourceImage.width, height: sourceImage.height }) {
1234-
const canvas = document.createElement("canvas");
1235-
canvas.width = dimensions.width;
1236-
canvas.height = dimensions.height;
1237-
const context = canvas.getContext("2d", { willReadFrequently: true });
1238-
context.fillStyle = "#ffffff";
1239-
context.fillRect(0, 0, canvas.width, canvas.height);
1240-
context.imageSmoothingEnabled = true;
1241-
context.imageSmoothingQuality = canvas.width < sourceImage.width ? "medium" : "high";
1242-
context.drawImage(sourceImage, 0, 0);
1233+
function applyFaxArtifacts(data, width, height, preset) {
1234+
const streakStrength = preset.streakStrength ?? 0.04;
1235+
const dropoutChance = preset.dropoutChance ?? 0.002;
12431236

1244-
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
1245-
const data = imageData.data;
1237+
for (let y = 0; y < height; y += 1) {
1238+
if (Math.random() < streakStrength) {
1239+
const segmentLength = Math.max(10, Math.round(width * (0.05 + (Math.random() * 0.18))));
1240+
const startX = Math.max(0, Math.floor(Math.random() * Math.max(1, width - segmentLength)));
1241+
const streakValue = Math.random() < 0.82 ? 255 : 0;
1242+
1243+
for (let x = startX; x < startX + segmentLength; x += 1) {
1244+
const index = ((y * width) + x) * 4;
1245+
data[index] = streakValue;
1246+
data[index + 1] = streakValue;
1247+
data[index + 2] = streakValue;
1248+
}
1249+
}
1250+
1251+
for (let x = 0; x < width; x += 1) {
1252+
const index = ((y * width) + x) * 4;
1253+
if (data[index] === 0 && Math.random() < dropoutChance) {
1254+
data[index] = 255;
1255+
data[index + 1] = 255;
1256+
data[index + 2] = 255;
1257+
}
1258+
}
1259+
}
1260+
}
1261+
1262+
function applyScanEffect(sourceImage, controls, dimensions = { width: sourceImage.width, height: sourceImage.height }) {
12461263
const preset = presetSettings[controls.preset];
1264+
const processingScale = preset.processingScale ?? 1;
1265+
const processingWidth = Math.max(1, Math.round(dimensions.width * processingScale));
1266+
const processingHeight = Math.max(1, Math.round(dimensions.height * processingScale));
1267+
const processingCanvas = document.createElement("canvas");
1268+
processingCanvas.width = processingWidth;
1269+
processingCanvas.height = processingHeight;
1270+
const processingContext = processingCanvas.getContext("2d", { willReadFrequently: true });
1271+
processingContext.fillStyle = "#ffffff";
1272+
processingContext.fillRect(0, 0, processingCanvas.width, processingCanvas.height);
1273+
processingContext.imageSmoothingEnabled = true;
1274+
processingContext.imageSmoothingQuality = processingCanvas.width < sourceImage.width ? "medium" : "high";
1275+
processingContext.drawImage(sourceImage, 0, 0, processingCanvas.width, processingCanvas.height);
1276+
1277+
const imageData = processingContext.getImageData(0, 0, processingCanvas.width, processingCanvas.height);
1278+
const data = imageData.data;
12471279
const contrastFactor = controls.contrast / 100;
12481280
const brightnessOffset = controls.brightness * 1.8;
12491281
const grainAmount = controls.grain;
@@ -1252,12 +1284,12 @@ function applyScanEffect(sourceImage, controls, dimensions = { width: sourceImag
12521284
const binaryMix = preset.binaryMix ?? 0.72;
12531285
const lineNoise = preset.lineNoise ?? 0.02;
12541286
const tonerNoise = preset.tonerNoise ?? 0.01;
1255-
const rowJitterSeed = Math.max(1, Math.round(canvas.height / 140));
1287+
const rowJitterSeed = Math.max(1, Math.round(processingCanvas.height / 140));
12561288

12571289
for (let index = 0; index < data.length; index += 4) {
12581290
const pixelIndex = index / 4;
1259-
const x = pixelIndex % canvas.width;
1260-
const y = Math.floor(pixelIndex / canvas.width);
1291+
const x = pixelIndex % processingCanvas.width;
1292+
const y = Math.floor(pixelIndex / processingCanvas.width);
12611293
const red = data[index];
12621294
const green = data[index + 1];
12631295
const blue = data[index + 2];
@@ -1266,7 +1298,7 @@ function applyScanEffect(sourceImage, controls, dimensions = { width: sourceImag
12661298
const noise = (Math.random() - 0.5) * grainAmount * 2;
12671299
const scanLineOffset = Math.sin((y / rowJitterSeed) * 0.9) * 255 * lineNoise;
12681300
const feedOffset = ((y % 3) === 0 ? -1 : 1) * 255 * lineNoise * 0.18;
1269-
const edgeFalloff = ((x / Math.max(1, canvas.width)) - 0.5) * 255 * lineNoise * 0.08;
1301+
const edgeFalloff = ((x / Math.max(1, processingCanvas.width)) - 0.5) * 255 * lineNoise * 0.08;
12701302
const adjustedGray = clamp(((baseGray - 128) * contrastFactor) + 128 + brightnessOffset + noise + scanLineOffset + feedOffset + edgeFalloff);
12711303
const cleanupLift = controls.autoCleanup ? Math.max(0, adjustedGray - 168) * 0.72 : 0;
12721304
const cleanedGray = clamp(adjustedGray + cleanupLift);
@@ -1288,6 +1320,11 @@ function applyScanEffect(sourceImage, controls, dimensions = { width: sourceImag
12881320
grayscaleValue = 255;
12891321
}
12901322

1323+
if (preset.pureMono) {
1324+
const rowThreshold = threshold + Math.sin(y / 5.5) * 8 + (((y % 4) - 1.5) * 1.5);
1325+
grayscaleValue = grayscaleValue < rowThreshold ? 0 : 255;
1326+
}
1327+
12911328
if (backgroundCandidate || (controls.autoCleanup && grayscaleValue > threshold + 14)) {
12921329
grayscaleValue = 255;
12931330
}
@@ -1297,11 +1334,28 @@ function applyScanEffect(sourceImage, controls, dimensions = { width: sourceImag
12971334
data[index + 2] = grayscaleValue;
12981335
}
12991336

1300-
context.putImageData(imageData, 0, 0);
1301-
applyPaperWash(context, canvas.width, canvas.height);
1302-
applyVignette(context, canvas.width, canvas.height, controls.vignette);
1303-
applyShadowEdge(context, canvas.width, canvas.height);
1304-
return canvas;
1337+
if (preset.pureMono) {
1338+
applyFaxArtifacts(data, processingCanvas.width, processingCanvas.height, preset);
1339+
}
1340+
1341+
processingContext.putImageData(imageData, 0, 0);
1342+
1343+
const outputCanvas = document.createElement("canvas");
1344+
outputCanvas.width = dimensions.width;
1345+
outputCanvas.height = dimensions.height;
1346+
const outputContext = outputCanvas.getContext("2d", { willReadFrequently: true });
1347+
outputContext.fillStyle = "#ffffff";
1348+
outputContext.fillRect(0, 0, outputCanvas.width, outputCanvas.height);
1349+
outputContext.imageSmoothingEnabled = !preset.pureMono;
1350+
outputContext.imageSmoothingQuality = preset.pureMono ? "low" : "high";
1351+
outputContext.drawImage(processingCanvas, 0, 0, outputCanvas.width, outputCanvas.height);
1352+
1353+
if (!preset.pureMono) {
1354+
applyPaperWash(outputContext, outputCanvas.width, outputCanvas.height);
1355+
applyShadowEdge(outputContext, outputCanvas.width, outputCanvas.height);
1356+
}
1357+
applyVignette(outputContext, outputCanvas.width, outputCanvas.height, controls.vignette);
1358+
return outputCanvas;
13051359
}
13061360

13071361
function applyPaperWash(context, width, height) {

index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ <h1>Turn photos into clean PDF documents right in your browser.</h1>
9595
<option value="clean" selected>Office scanner</option>
9696
<option value="classic">Photocopier</option>
9797
<option value="high-contrast">Receipts and forms</option>
98-
<option value="fax">Fax / hard mono</option>
98+
<option value="fax">Fax / 1-bit mono</option>
9999
</select>
100100
</label>
101101

0 commit comments

Comments
 (0)