My friend Andrew recently posted a teaser for a new project he’s working on, but with part of the headline pixelated to obscure what the project actually is. My curiosity got the best of me and I decided to do what any self-respecting geek would do: write a program to figure out what the censored text said.

Ultimately I failed to recover most of the censored text (except “to”), so I had to cheat a little. The following video is the program running on a very similar image I created. This proves it works in ideal conditions, but needs some improvement to work in less than ideal cases.

(and no, as far as I know my friend’s project has nothing to do with eating monkeys)

Applying a filter like Photoshop’s “mosaic” filter obscures the original data, but doesn’t remove it entirely. If we can reconstruct an image with *known* text that looks very similar to original image, then we can be pretty sure the original text is the same as our known text. This is very similar in principle to brute-force cracking a password hash. For a more detailed explanation see this article.

Photoshop was an obvious choice since I needed to recreate the exact same fancy styling as the original image, then apply the exact same mosaic filter. I figured I would have to write a script that tells Photoshop to generate images, then use an external tool to actually compare them to the original.

It turns out that Photoshop CS3 has all the features necessary to pull the whole thing off without any other programs or tools. The most important feature is the JavaScript scripting environment built into Photoshop, which is far more powerful than the AppleScript environment (and a *much* nicer language, in my opinion).

CS3 added two other features that are critical to this task: Smart Filters, and Measurements. Smart Filters lets you edit a layer (namely the text with effects applied) *after* you apply a filter that would have previously require rasterization. This lets us apply the censoring filter to our styled text, and later change the text without having to manually reapply the filter. The “measurements” feature lets you record various statistics about an image or portion of an image: in our case we’ll want the “average gray value” of the “difference” between the original and generated images.

First we need to prepare the environment. Open the original image in Photoshop, and attempt to replicate the original un-censored text as closely as possible (you need *some* uncensored text as a reference). Place your text layer on top of the original and toggle between normal and “difference” blending modes to see how you close you are. Ideally everything will be black in “difference” mode. It’s very important to precisely match the font, size, spacing, color, effects like drop shadows or outlines, and even the background. If these are off even by a little bit it will throw things off. I ended up having to cheat because I couldn’t match the slick styling of the original text with my lame Photoshop design skills.

Once the text matches and is lined up perfectly, select the layer then choose “Convert for Smart Filters” from the “Filter” menu. Now select the censored portion of the text and apply the same filter used on the original image, again matching it as closely as possible. For the mosaic filter, you can line up the “grid” by adjusting the origin and size of the selection (yeah, it’s a pain).

Finally, make sure your layer is on top of the original, and the blending mode on your layer is set to “difference”. Double-click the Smart Object layer to open it’s source document, and adjust the variables listed at the top of the JavaScript to match the names and layers. Also, in the menu “Analysis”: “Select Data Points”: “Custom…” make sure only “Gray Value (Mean)” is checked.

Code

Rather than attempting to explain it in detail here, just read the code and comments. Here’s a quick summary:

Start with the first character. Try setting it to each of the possibilities (a through z, and a space), and record the difference score between the original image and generated image. Only look at the first half of the current character (since the second half will be influenced by the *next* character). Sort the results. Lower scores are better (less different) Now try each of the top 3 characters along with every possibility for the *next* character. This time record score for the whole width of the current character since we’re checking the next character as well. Pick the best choice, either the best permutation out of all 81 combinations (3 best * 27 possible), or out of the 3 averages for each best. Repeat for the next character until done.

// change these parameters based on document names and layer ordering

baseDocName = "base.psd" ;

baseDocTextLayer = 0 ;

textDocName = "The easy way to do somethingss12.psb" ;

textDocTextLayer = 0 ; baseDocName =baseDocTextLayer =textDocName =textDocTextLayer = knownString = "The easy way "; // the part of the string that’s already known

missingLength = 20; // number of characters to figure out



method = 3;

debug = false; function main()

{

baseDoc = documents[baseDocName];

textDoc = documents[textDocName]; // get the top left corner of the text layer in the main doc

var mainBounds = baseDoc.artLayers[baseDocTextLayer].bounds,

mainX = mainBounds[0].as("px"),

mainY = mainBounds[1].as("px");



// possible characters include space and lowercase.

var possibleCharacters = [" "];

for (var i = 0; i < 26; i++)

{

possibleCharacters.push(String.fromCharCode("a".charCodeAt(0) + i));

//possibleCharacters.push(String.fromCharCode("A".charCodeAt(0) + i)); // uncomment for uppercase letters

} var fudgeFactor = 3, // number of top choices to try

guess = ""; // guessed letters so far



for (var charNum = 0; charNum < missingLength; charNum++)

{

results = [];



// get the beginning and potential end (width of a "M") of the next character

var w1 = getStringBounds(knownString + guess),

w2 = getStringBounds(knownString + guess + "M"); // PASS 1: half the potential width, since we’re not looking at the next character yet



// half the width of "M"

setSelection(mainX, mainY, (w1[2].as("px") + w2[2].as("px")) / 2, 15);//w2[3].as("px"));



// get the score for every letter

for (var i = 0; i < possibleCharacters.length; i++)

{

var val = getStringScore(knownString + guess + possibleCharacters[i])



var res = { ch: possibleCharacters[i], v: val };

results.push(res);

} // sort from best (lowest) to worst score

results = results.sort(function (a,b) { return a.v – b.v; });



// method 1: too simple, poor results

if (method == 1)

{

guess += results[0].ch;

}

else

{

// PASS 2: full (potential) width of the current character, testing each of the few top matches and every possible next character



// full width of "M"

setSelection(mainX, mainY, w2[2].as("px"), 15);//w2[3].as("px"));



var minValue = Number.MAX_VALUE,

minChar = null,

minSum = Number.MAX_VALUE,

minSumChar = null;



// try the few best from the first pass

for (var i = 0; i < fudgeFactor; i++)

{

var sum = 0;

for (var j = 0; j < possibleCharacters.length; j++)

{

// get the score for the potential best PLUS each possible next character

var val = getStringScore(knownString + guess + results[i].ch + possibleCharacters[j])



sum += val;



if (val < minValue)

{

minValue = val;

minChar = results[i].ch;

}

}

if (sum < minSum)

{

minSum = sum;

minSumChar = results[i].ch;

}

}



// if the results aren’t consistent let us know

if (debug && results[0].ch != minSumChar || minChar != minSumChar)

alert(minChar + "," + minSumChar + " (" +results[0].ch + "," + results[1].ch+ "," + results[2].ch+ ")");



if (method == 2)

{

// method 2: best of all permutations

guess += minChar;

}

else

{

// method 3: best average

guess += minSumChar;

}

}

WaitForRedraw();

}

} // measure the gray value mean in the current selection

function getMeasurement()

{

// delete existing measurements

app.measurementLog.deleteMeasurements();



// record new measurement

app.activeDocument = baseDoc;

app.activeDocument.recordMeasurements();//MeasurementSource.MEASURESELECTION, ["GrayValueMean"]);



// export measurements to a file