# Force an Average on an Image

Write a program that takes in a standard truecolor image and a single 24-bit RGB color (three numbers from 0 to 255). Modify the input image (or output a new image with the same dimensions) such that its average color is exactly the single color that was input. You may modify the pixels in the input image in any way you like to achieve this, but the goal is to make the color changes as visually unnoticeable as possible.

The average color of an RGB image is really a set of three arithmetic means, one for each color channel. The average red value is the sum of the red values across all the pixels in the image divided by the total number of pixels (the image area), rounded down to the nearest integer. The green and blue averages are calculated in the same way.

This Python 2 (with PIL) script can compute the average color of most image file formats:

from PIL import Image
print 'Enter image file'
im = Image.open(raw_input()).convert('RGB')
avg = [0, 0, 0]
for x in range(im.size):
for y in range(im.size):
for i in range(3):
avg[i] += pixels[x, y][i]
print 'The average color is', tuple(c // (im.size * im.size) for c in avg)


(There are similar average color programs here, but they don't necessarily do the exact same calculation.)

The main requirement for your program is that for any input image, its corresponding output's average color must exactly match the color that was input - as judged by the Python snippet or some equivalent code. The output image must also have the exact same dimensions as the input image.

So you could technically submit a program that simply colors the entire input the specified average color (because the average would always be that color), but this is a popularity contest - the submission with the highest number of votes will win, and such a trivial submission will not get you many upvotes. Novel ideas like taking advantage of quirks in human vision, or shrinking the image down and drawing a colored border around it will (hopefully) get you votes.

Note that certain combinations of average colors and images necessitate extremely noticeable color changes. For example, if the average color to match were black (0, 0, 0), any input image would need to be made completely black because if any pixels had non-zero values, they would make the average non-zero as well (barring round-off errors). Keep such limitations in mind when voting.

## Test Images

Some images and their default average colors to play around with. Click for full sizes.

A. average (127, 127, 127) From fejesjoco's Images with all colors answer. Found original on his blog.

B. average (62, 71, 73)

C. average (115, 112, 111)

D. average (154, 151, 154)

E. average (105, 103, 102)

F. average (75, 91, 110)

## Notes

• The exact input and output formats and image file types your program uses does not matter much. Just make sure it is clear how to use your program.
• It is probably a good idea (but not technically a requirement) that if an image already has the goal average color, it should be output as is.
• Please post test images with the average color input as either (150, 100, 100) or (75, 91, 110), so voters can see the same inputs across different solutions. (Posting more examples than this is fine, even encouraged.)
• Participants get to choose the input colors they use to demonstrate the effectiveness of their solution? Doesn't that make it difficult for people to compare the solutions? In the extreme case, somebody could choose input colors that are very similar to the average of the image, and it would look like their solution is very effective. Jul 23, 2015 at 6:36
• @vihan1086 If I've understood correctly, the average colour is provided as a 24 bit RGB colour input, not found from an input image. Jul 23, 2015 at 6:50
• It might be interesting to use @vihan1086's interpretation, and use the example images as the source of input colours so one image is displayed in the average colour of another. This way different answers can be compared fairly. Jul 23, 2015 at 6:56
• The main problem with that is most of them have an average that's very close to grey. Starry Night is probably the furthest from that, but the rest average out pretty flatly. Jul 23, 2015 at 21:35
• @RetoKoradi Hopefully voters will be smart enough to take such things into account, though I've added a note on what default average colors to use. Jul 24, 2015 at 0:08

# Python 2 + PIL, simple colour scaling

from PIL import Image
import math

INFILE = "street.jpg"
OUTFILE = "output.png"
AVERAGE = (150, 100, 100)

im = Image.open(INFILE)
im = im.convert("RGB")
width, height = prev_size = im.size
pixels = {(x, y): list(im.getpixel((x, y)))
for x in range(width) for y in range(height)}

def get_avg():
total_rgb = [0, 0, 0]

for x in range(width):
for y in range(height):
for i in range(3):
total_rgb[i] += int(pixels[x, y][i])

return [float(x)/(width*height) for x in total_rgb]

curr_avg = get_avg()

while tuple(int(x) for x in curr_avg) != AVERAGE:
print curr_avg
non_capped = [0, 0, 0]
total_rgb = [0, 0, 0]

for x in range(width):
for y in range(height):
for i in range(3):
if curr_avg[i] < AVERAGE[i] and pixels[x, y][i] < 255:
non_capped[i] += 1
total_rgb[i] += int(pixels[x, y][i])

elif curr_avg[i] > AVERAGE[i] and pixels[x, y][i] > 0:
non_capped[i] += 1
total_rgb[i] += int(pixels[x, y][i])

ratios = [1 if z == 0 else
x/(y/float(z))
for x,y,z in zip(AVERAGE, total_rgb, non_capped)]

for x in range(width):
for y in range(height):
col = []

for i in range(3):
new_col = (pixels[x, y][i] + 0.01) * ratios[i]
col.append(min(255, max(0, new_col)))

pixels[x, y] = tuple(col)

curr_avg = get_avg()

print curr_avg

for pixel in pixels:
im.putpixel(pixel, tuple(int(x) for x in pixels[pixel]))

im.save(OUTFILE)


Here's a naïve approach which should serve as a good baseline. At each iteration, we compare our current average with the desired average, and scale the RGB of each pixel by the according ratio. We have to be a bit careful though, for two reasons:

• Scaling 0 still results in 0, so before we scale we add something small (here 0.01)

• RGB values are between 0 and 255, so we need to adjust the ratio accordingly to make up for the fact that scaling capped pixels doesn't do anything.

The images save as PNG because saving as JPG seems to mess up the colour averages.

# Sample output

## (75, 91, 110), Starry Night palette

• You definitely want to use a image format with non-lossy compression for this. So JPEG is not a good option. Jul 23, 2015 at 16:47
• You can always count on Sp for cool image challenge solutions. Jul 23, 2015 at 18:19

# C++, gamma correction

This does a brightness adjustment of the image using a simple gamma correction, with the gamma value determined separately for each component to match the target average.

The high level steps are:

1. Read image and extract histogram for each color component.
2. Perform a binary search of the gamma value for each component. A binary search is performed on the gamma values, until the resulting histogram has the desired average.
3. Read the image a second time, and apply the gamma correction.

All image input/output uses PPM files in ASCII. Images were converted from/to PNG using GIMP. The code was run on a Mac, image conversions were done on Windows.

### Code:

#include <cmath>
#include <string>
#include <vector>
#include <sstream>
#include <fstream>
#include <iostream>

static inline int mapVal(int val, float gamma)
{
float relVal = (val + 1.0f) / 257.0f;
float newRelVal = powf(relVal, gamma);

int newVal = static_cast<int>(newRelVal * 257.0f - 0.5f);
if (newVal < 0)
{
newVal = 0;
}
else if (newVal > 255)
{
newVal = 255;
}

return newVal;
}

struct Histogram
{
Histogram();

int getAvg(int colIdx) const;
void adjust(const Histogram& origHist, int colIdx, float gamma);

int pixCount;
std::vector<int> freqA;
};

Histogram::Histogram()
: pixCount(0)
{
for (int iCol = 0; iCol < 3; ++iCol)
{
freqA[iCol].resize(256, 0);
}
}

{
for (int iCol = 0; iCol < 3; ++iCol)
{
freqA[iCol].assign(256, 0);
}

std::ifstream inStrm(fileName);

std::string format;
inStrm >> format;
if (format != "P3")
{
std::cerr << "invalid PPM header" << std::endl;
return false;
}

int w = 0, h = 0;
inStrm >> w >> h;
if (w <= 0 || h <= 0)
{
std::cerr << "invalid size" << std::endl;
return false;
}

int maxVal = 0;
inStrm >> maxVal;
if (maxVal != 255)
{
std::cerr << "invalid max value (255 expected)" << std::endl;
return false;
}

pixCount = w * h;

int sumR = 0, sumG = 0, sumB = 0;
for (int iPix = 0; iPix < pixCount; ++iPix)
{
int r = 0, g = 0, b = 0;
inStrm >> r >> g >> b;
++freqA[r];
++freqA[g];
++freqA[b];
}

return true;
}

int Histogram::getAvg(int colIdx) const
{
int avg = 0;
for (int val = 0; val < 256; ++val)
{
avg += freqA[colIdx][val] * val;
}

return avg / pixCount;
}

void Histogram::adjust(const Histogram& origHist, int colIdx, float gamma)
{
freqA[colIdx].assign(256, 0);

for (int val = 0; val < 256; ++val)
{
int newVal = mapVal(val, gamma);
freqA[colIdx][newVal] += origHist.freqA[colIdx][val];
}
}

void mapImage(const std::string fileName, float gammaA[])
{
std::ifstream inStrm(fileName);

std::string format;
inStrm >> format;

int w = 0, h = 0;
inStrm >> w >> h;

int maxVal = 0;
inStrm >> maxVal;

std::cout << "P3" << std::endl;
std::cout << w << " " << h << std::endl;
std::cout << "255" << std::endl;

int nPix = w * h;

for (int iPix = 0; iPix < nPix; ++iPix)
{
int inRgb = {0};
inStrm >> inRgb >> inRgb >> inRgb;

int outRgb = {0};
for (int iCol = 0; iCol < 3; ++iCol)
{
outRgb[iCol] = mapVal(inRgb[iCol], gammaA[iCol]);
}

std::cout << outRgb << " " << outRgb << " "
<< outRgb << std::endl;
}
}

int main(int argc, char* argv[])
{
if (argc < 5)
{
std::cerr << "usage: " << argv
<< " ppmFileName targetR targetG targetB"
<< std::endl;
return 1;
}

std::string inFileName = argv;

int targAvg = {0};
std::istringstream strmR(argv);
strmR >> targAvg;
std::istringstream strmG(argv);
strmG >> targAvg;
std::istringstream strmB(argv);
strmB >> targAvg;

Histogram origHist;
{
return 1;
}

Histogram newHist(origHist);
float gammaA = {0.0f};

for (int iCol = 0; iCol < 3; ++iCol)
{
float minGamma = 0.0f;
float maxGamma = 1.0f;
for (;;)
{
int avg = newHist.getAvg(iCol);
if (avg <= targAvg[iCol])
{
break;
}
maxGamma *= 2.0f;
}

for (;;)
{
float midGamma = 0.5f * (minGamma + maxGamma);

int avg = newHist.getAvg(iCol);
if (avg < targAvg[iCol])
{
maxGamma = midGamma;
}
else if (avg > targAvg[iCol])
{
minGamma = midGamma;
}
else
{
gammaA[iCol] = midGamma;
break;
}
}
}

mapImage(inFileName, gammaA);

return 0;
}


The code itself is fairly straightforward. One subtle but important detail is that, while the color values are in the range [0, 255], I map them to the gamma curve as if the range were [-1, 256]. This allows the average to be forced to 0 or 255. Otherwise, 0 would always remain 0, and 255 would always remain 255, which might never allow for an average of 0/255.

To use:

1. Save the code in a file with extension .cpp, e.g. force.cpp.
2. Compile with c++ -o force -O2 force.cpp.
3. Run with ./force input.ppm targetR targetG target >output.ppm.

### Sample output for 40, 40, 40

Note that the images for all the larger samples are included as JPEGs since they exceed the SE size limit as PNGs. Since JPEG is a lossy compression format, they may not exactly match the target average. I have the PNG version of all files, which matches exactly.

### Sample output for 75, 91, 110:

• I had to shrink down the other images to meet the limit - maybe try that? Jul 27, 2015 at 9:38
• @Sp3000 Ok, got all the images included now. Also with thumbnails now. I ended up using the JPEG version for the large ones. Actually, one of them was below the size limit, but it looks like it was auto-converted to JPEG. The first and last examples are still PNGs. Jul 28, 2015 at 5:44

# Python 2 + PIL

from PIL import Image
import random
import math

SOURCE = 'input.png'
OUTPUT = 'output.png'
AVERAGE = [150, 100, 100]

im = Image.open(SOURCE).convert('RGB')
w = im.size
h = im.size
npixels = w * h

maxdiff = 0.1

# for consistent results...
random.seed(42)
order = range(npixels)
random.shuffle(order)

def calc_sum(pixels, w, h):
sums = [0, 0, 0]
for x in range(w):
for y in range(h):
for i in range(3):
sums[i] += pixels[x, y][i]
return sums

def get_coordinates(index, w):
return tuple([index % w, index // w])

desired_sums = [AVERAGE * npixels, AVERAGE * npixels, AVERAGE * npixels]

sums = calc_sum(pixels, w, h)
for i in range(3):
while sums[i] != desired_sums[i]:
for j in range(npixels):
if sums[i] == desired_sums[i]:
break
elif sums[i] < desired_sums[i]:
coord = get_coordinates(order[j], w)
pixel = list(pixels[coord])
delta = int(maxdiff * (255 - pixel[i]))
if delta == 0 and pixel[i] != 255:
delta = 1
delta = min(delta, desired_sums[i] - sums[i])

sums[i] += delta
pixel[i] += delta
pixels[coord] = tuple(pixel)
else:
coord = get_coordinates(order[j], w)
pixel = list(pixels[coord])
delta = int(maxdiff * pixel[i])
if delta == 0 and pixel[i] != 0:
delta = 1
delta = min(delta, sums[i] - desired_sums[i])

sums[i] -= delta
pixel[i] -= delta
pixels[coord] = tuple(pixel)

# output image
for x in range(w):
for y in range(h):
im.putpixel(tuple([x, y]), pixels[tuple([x, y])])

im.save(OUTPUT)


This iterates through each pixel in a random order, and reduces the distance between each component of the color of the pixel and 255 or 0 (depending on whether the current average is less or greater than the desired average). The distance is reduced by a fixed multiplicative factor. This is repeated until the desired average is obtained. The reduction is always at least 1, unless the color is 255 (or 0), to ensure that the processing does not stall once the pixel is close to white or black.

# Java

An RNG based approach. A bit slow for large input images.

import java.awt.Color;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.*;

import javax.imageio.ImageIO;

public class Averager {
static Random r;
static long sigmaR=0,sigmaG=0,sigmaB=0;
static int w,h;
static int rbar,gbar,bbar;
static BufferedImage i;
private static File file;
static void upRed(){
int x=r.nextInt(w);
int y=r.nextInt(h);
Color c=new Color(i.getRGB(x, y));
if(c.getRed()==255)return;
sigmaR++;
c=new Color(c.getRed()+1,c.getGreen(),c.getBlue());
i.setRGB(x, y,c.getRGB());
}
static void downRed(){
int x=r.nextInt(w);
int y=r.nextInt(h);
Color c=new Color(i.getRGB(x, y));
if(c.getRed()==0)return;
sigmaR--;
c=new Color(c.getRed()-1,c.getGreen(),c.getBlue());
i.setRGB(x, y,c.getRGB());
}
static void upGreen(){
int x=r.nextInt(w);
int y=r.nextInt(h);
Color c=new Color(i.getRGB(x, y));
if(c.getGreen()==255)return;
sigmaG++;
c=new Color(c.getRed(),c.getGreen()+1,c.getBlue());
i.setRGB(x, y,c.getRGB());
}
static void downGreen(){
int x=r.nextInt(w);
int y=r.nextInt(h);
Color c=new Color(i.getRGB(x, y));
if(c.getGreen()==0)return;
sigmaG--;
c=new Color(c.getRed(),c.getGreen()-1,c.getBlue());
i.setRGB(x,y,c.getRGB());
}
static void upBlue(){
int x=r.nextInt(w);
int y=r.nextInt(h);
Color c=new Color(i.getRGB(x, y));
if(c.getBlue()==255)return;
sigmaB++;
c=new Color(c.getRed(),c.getGreen(),c.getBlue()+1);
i.setRGB(x, y,c.getRGB());
}
static void downBlue(){
int x=r.nextInt(w);
int y=r.nextInt(h);
Color c=new Color(i.getRGB(x, y));
if(c.getBlue()==0)return;
sigmaB--;
c=new Color(c.getRed(),c.getGreen(),c.getBlue()-1);
i.setRGB(x,y,c.getRGB());
}
public static void main(String[]a) throws Exception{
Scanner in=new Scanner(System.in);
rbar=in.nextInt();
gbar=in.nextInt();
bbar=in.nextInt();
w=i.getWidth();
h=i.getHeight();
final int npix=w*h;
r=new Random(npix*(long)i.hashCode());
for(int x=0;x<w;x++){
for(int y=0;y<h;y++){
Color c=new Color(i.getRGB(x, y));
sigmaR+=c.getRed();
sigmaG+=c.getGreen();
sigmaB+=c.getBlue();
}
}
while(sigmaR/npix<rbar){
upRed();
}
while(sigmaR/npix>rbar){
downRed();
}
while(sigmaG/npix<gbar){
upGreen();
}
while(sigmaG/npix>gbar){
downGreen();
}
while(sigmaB/npix<bbar){
upBlue();
}
while(sigmaB/npix>bbar){
downBlue();
}
String k=file.getName().split("\\.");
ImageIO.write(i,"png",new File(k="out_"+k+".png"));
}
}


Tests:

(40,40,40)      (150,100,100)      (75,91,110)      