Adafruit mini thermal printer, part 1/?: getting better pictures

I bought an AdaFruit Mini thermal printer.

Code on github: https://github.com/edrosten/adafruit-thermal-printer-driver. Note: I wrote these posts as I went along so there may be bugs in the code snippets which are fixed later. I recommend checking the GitHub source before using a snippet.

It’s pretty cute, and it’s actually very old school in terms of its function. Firstly in a very old fashioned twist, it comes with a full manual documenting every single control code. Not only that but the printer is surprisingly capable and it’s designed to work with very low end driving systems. It doesn’t just print bitmaps, it has various fonts and modes (double height, width, etc), you can download custom fonts and bitmaps to print on demand.  You can print upside down and back to front so the text looks the right way round if you’re facing the printer (super cute!). I has justification modes, bold and underline. It can even print barcodes!

You know this reminds me of when I was 14(?) and got my first computer, a BBC Micro complete with a 5.25″ floppy drive and a printer. The printer came with a manual with full documentation of all the control codes and I devoured them and wrote a basic typesetter like system in which I did my school projects.

cdst5nkwyaew1fw

So where was I?

Oh yes, well I don’t actually need most of those features. I’m planing on driving it from Linux (on a Pi), which means it’ll be driven by GhostScript via CUPS and will print bitmaps. And not use any of those features.

Turns out Adafruit provide a CUPS driver. Apparently provided from one provided by the printer maufacturer? So, I installed it and this is the result:

20191202_181147

woo! it prints! Except… the output isn’t great. The printer is monochrome and the pictures come out halftoned using a halftone screen. While that’s a fine choice for various kinds of printing, it’s not great for a device with independent pixels. For that, a dithering method such as Floyd-Steinberg would be much better. Also it’s messing up the first line and printing junk, but you know details, details.

PostScript, being designed for proper printing has native support for halftoning. It doesn’t for dithering, and it turns out there’s no way to persuade it to emit a monochrome bitmap using dithering instead of halftone screens. If you want dithering, you need to do it in the driver. So, I’m going to need a custom driver.

So I first need to understand printing.

Printing on Linux greatly simplified

Printing on Linux isn’t simple. Partly this is because printing in general is not simple. And partly it’s because printing has changed a lot over the years and there are lots of vestigial bits lying around. For common, modern systems the order of operations is roughly:

  1. CUPS accepts jobs (and provides information to the print dialogs).
  2. CUPS examines the file type and decides what to do next, e.g. whether to run it through GhostScript.
  3. CUPS runs it through ghostscript generating a stream in CUPS raster format. This is a simple bitmap format with a C API.
  4. CUPS runs some arbitrary filter program.
  5. Filter program transforms CUPS bitmap into printer control codes.
  6. CUPS routes the resulting data to the correct device.

GhostScript also has some printer drivers built in, an there are various other filter schemes (GhostScript is one of many) such as foomatic, and of course printers can accept plain text too. I’m not really interested in those so I’ll stick to the sequence above.

Steps 2-4 are controlled by a PPD (PostScript Printer Description) file, and 5 is a program which reads in CUPS bitmap data and emits control codes. The CUPS raster format is well documented but it seems simpler to use the C API, especially as I have a working driver to cadge from.

What I’m going to do first is figure out how to print out what I want (i.e. the right control codes) and then figure out how to work it into CUPS.

Getting CUPS raster data and a simple driver

The first job is to get the input data. After a bunch of cargo-culting, I got this script:

DPI=203.2
gs -dPARANOIDSAFER -dNOPAUSE -dBATCH -sstdout=%stderr -sOutputFile=%stdout \
-sDEVICE=cups -sMediaClass=PwgRaster -sOutputType=Automatic -r${DPI}x${DPI} \
-dDEVICEWIDTH=384 -dDEVICEHEIGHT=384 -dcupsBitsPerColor=8 -dcupsColorOrder=0 \
-dcupsColorSpace=0 -dcupsBorderlessScalingFactor=0.0000 -dcupsInteger1=1 \
-dcupsInteger2=1 -scupsPageSizeName=na_letter_8.5x11in -I/usr/share/cups/fonts \
"$@"


I don’t remember precisely how I found all the various bits. The important things are that it’s colourspace 0 (white), 8 bits per colour, CUPS raster format, 384 pixels wide and 8 pixels per mm. Everything else is just necessary guff (IO redirection, batch and no pause) or irrelevant stuff I never deleted.

I then basically deleted everything except the stream processing from the driver, then deleted that and started writing from scratch. After lots of head scratching and making a lot of mistakes I read the manual more carefully (bitmaps are always a multiple of 8 pixels wide) and got this code up and running:

#include <cups/raster.h>

#include <iostream>
#include <vector>
#include <array>
#include <utility>
#include <cmath>

using std::clog;
using std::cout;
using std::endl;
using std::vector;
using std::array;


constexpr unsigned char ESC = 0x1b;
constexpr unsigned char GS = 0x1d;

// Write out a std::array of bytes as bytes.  This will form the basis
// of sending data to the printer.
template<size_t N>
std::ostream& operator<<(std::ostream& out, const array<unsigned char, N>& a){
	out.write(reinterpret_cast<const char*>(a.data()), a.size());
	return out;
}

array<unsigned char, 2> binary(uint16_t n){
	return {{static_cast<unsigned char>(n&0xff), static_cast<unsigned char>(n >> 8)}};
}


void printerInitialise(){
	cout << ESC << '\x40';
}

// enter raster mode and set up x and y dimensions
void rasterheader(uint16_t xsize, uint16_t ysize)
{
	// Page 33 of the manual
	// The x size is the number of bytes per row, so the number of pixels
	// is always a multiple of 8
	cout << GS << 'v' << '0' << '\0' << binary((xsize+7)/8) << binary(ysize);
}


int main(){

	cups_raster_t *ras = cupsRasterOpen(0, CUPS_RASTER_READ);
	cups_page_header2_t header;
	int page = 0;

	while (cupsRasterReadHeader2(ras, &header))
	{
		/* setup this page */
		page ++;
		clog << "PAGE: " << page << " " << header.NumCopies << "\n";
		clog << "BPP: " << header.cupsBitsPerPixel << endl;
		clog << "BitsPerColor: " << header.cupsBitsPerColor << endl;
		clog << "Width: " << header.cupsWidth << endl;
		clog << "Height: " << header.cupsHeight << endl;

		// Input data buffer for one line
		vector<unsigned char> buffer(header.cupsBytesPerLine);
		
		clog << "Line bytes: " << buffer.size() << endl;
		printerInitialise();

		/* read raster data */
		for (unsigned int y = 0; y < header.cupsHeight; y ++)
		{
			if (cupsRasterReadPixels(ras, buffer.data(), header.cupsBytesPerLine) == 0)
				break;

			//Print in MSB format, one line at a time
			rasterheader(header.cupsWidth, 1);
			unsigned char current=0;
			int bits=0;

			for(const auto& pixel: buffer){
				current |= (pixel>128)<<(7-bits);
				bits++;
				if(bits == 8){
					cout << current;
					bits = 0;
					current = 0;
				}
			}
			if(bits)
				cout << current;
		}

		/* finish this page */
	}
	cout << "\n\n\n";
	cupsRasterClose(ras);
}

To run the program, make an eps file, ideally with a cat in it. Then assuming the above script is called “to_cups.sh” and the compiled executable is called “rastertoadafruitmini”, you can run it with:

bash to_cups.sh cat.eps | ./rastertoadafruitmini | sudo dd of=/dev/usb/lp0 

Note that the quantisation is simply “greater than 128”, and the result is:

Note the false start at the top, and the slightly stretched image due to me converting to EPS badly. The underlying image is this:

CC BY 2.5, Copyright of (c) David Corby

It works! You’ll note I got the colours inverted, because I had 1 for white, and 0 for black, whereas 1 means print a pixel (i.e. black). The black bar is because of that and the page being white. The funny thing is that black areas feel incredibly wasteful of ink even though that makes no sense on a thermal printer.

Dithering the output

Clearly a simple threshold is not a very good way of converting greyscale to black and white. In fact it’s somewhat worse than the original halftoned image. The key is to employ some sort of dithering and this is best done by some sort of error diffusion algorithm.

The process works like this. While going in raster scan order:

  1. Quantize the pixel current to 0 or 255
  2. Work out the error between the quantized output and the pixel
  3. Add fractions of the error to nearby pixels which haven’t been processed yet (this is the error diffusion step)

There are quite a few articles on it, such as this excellent one. The most common/well know algorithm for images is the Floyd-Steinberg dithering algorithm. It’s popular because it’s low resource and efficient on simple processors. Since the target machine for this will be lavishly resourced (a Raspberry Pi) I decided to go for the Jarvis, Judice, Ninke algorithm which is essentially identical to Floyd-Steinberg but with a larger error diffusion window and is more expensive and gives slightly better results.

Here’s the code (with the new bits highlighted):

#include <cups/raster.h>

#include <iostream>
#include <vector>
#include <array>
#include <utility>
#include <cmath>
#include <algorithm>

using std::clog;
using std::cout;
using std::endl;
using std::vector;
using std::array;


constexpr unsigned char ESC = 0x1b;
constexpr unsigned char GS = 0x1d;

// Write out a std::array of bytes as bytes.  This will form the basis
// of sending data to the printer.
template<size_t N>
std::ostream& operator<<(std::ostream& out, const array<unsigned char, N>& a){
	out.write(reinterpret_cast<const char*>(a.data()), a.size());
	return out;
}

array<unsigned char, 2> binary(uint16_t n){
	return {{static_cast<unsigned char>(n&0xff), static_cast<unsigned char>(n >> 8)}};
}


void printerInitialise(){
	cout << ESC << '\x40';
}

// enter raster mode and set up x and y dimensions
void rasterheader(uint16_t xsize, uint16_t ysize)
{
	// Page 33 of the manual
	// The x size is the number of bytes per row, so the number of pixels
	// is always a multiple of 8
	cout << GS << 'v' << '0' << '\0' << binary((xsize+7)/8) << binary(ysize);
}


constexpr array<array<int, 5>, 3> diffusion_coefficients = {{
		{{0, 0, 0, 7, 5}},
		{{3, 5, 7, 5, 3}},
		{{1, 3, 5, 3, 1}}
}};
constexpr double diffusion_divisor=42;


int main(){

	cups_raster_t *ras = cupsRasterOpen(0, CUPS_RASTER_READ);
	cups_page_header2_t header;
	int page = 0;

	while (cupsRasterReadHeader2(ras, &header))
	{
		/* setup this page */
		page ++;
		clog << "PAGE: " << page << " " << header.NumCopies << "\n";
		clog << "BPP: " << header.cupsBitsPerPixel << endl;
		clog << "BitsPerColor: " << header.cupsBitsPerColor << endl;
		clog << "Width: " << header.cupsWidth << endl;
		clog << "Height: " << header.cupsHeight << endl;

		// Input data buffer for one line
		vector<unsigned char> buffer(header.cupsBytesPerLine);
		
		//Error diffusion data
		vector<vector<double>> errors(diffusion_coefficients.size(), vector<double>(buffer.size(), 0.0));

		clog << "Line bytes: " << buffer.size() << endl;
		printerInitialise();

		/* read raster data */
		for (unsigned int y = 0; y < header.cupsHeight; y ++)
		{
			if (cupsRasterReadPixels(ras, buffer.data(), header.cupsBytesPerLine) == 0)
				break;

			//Print in MSB format, one line at a time
			rasterheader(header.cupsWidth, 1);
			unsigned char current=0;
			int bits=0;

			for(int i=0; i < (int)buffer.size(); i++){
				
				//The actual pixel value with gamma correction
				double pixel = pow(buffer[i]/255., 1./2.2) + errors[0][i];
				double actual = pixel>.5?1:0;
				double error = pixel - actual; //This error is then distributed


				//Diffuse forward the error	
				for(int r=0; r < (int)diffusion_coefficients.size(); r++)
					for(int cc=0; cc < (int)diffusion_coefficients[0].size(); cc++){
						int c = cc - diffusion_coefficients[0].size()/2;
						if(c+i >= 0 && c+i < (int)buffer.size() && diffusion_coefficients[r][cc]){
							errors[r][i+c] += error * diffusion_coefficients[r][cc] / diffusion_divisor;
						}
					}

				current |= (pixel<0.5)<<(7-bits);
				bits++;
				if(bits == 8){
					cout << current;
					bits = 0;
					current = 0;
				}
			}
			if(bits)
				cout << current;

			
			//Roll the buffer round.
			std::rotate(errors.begin(), errors.begin()+1, errors.end());
			for(auto& p:errors.back())
				p=0;
			
	
		}

		/* finish this page */
	}
	cout << "\n\n\n";
	cupsRasterClose(ras);
}

And here’s the result

KITTY!!!!!!!!!!!!!

But can we do better? I’m not sure, but look at this:

You can draw on the paper using a finger nail. The faster you move at a given pressure the darker the line. I believe this is due to getting more heating. So, the paper is definitely analogue. Turns out the printer is too, kind of in that you can set the heat output per line (though not per pixel). The command is on page 47 and is the general control command. So what I did is print 255 solid lines, each one with a different heat output. The code (in AWK) is:

BEGIN{
	for(i=0; i < 255; i++){
		printf("%c7%c%c%c", 27, 64, i, 2)
		printf("\x1dv0\0%c\0\x01\0", 40)
		for(j=0; j < 40; j++)
			printf("\xff")
	}
	print "\n\n\n\n"
}

What got me going for ages is that the locale wasn’t C, to characters above 127 were getting mangled. Anyway the results is this:

That’s a yes! It’s a bit speckly, but it can definitely output greyscale. After a bit of messing around, I got the range. It goes from a timing (heat output is essentially controlled by setting the time the heating elements dwell on the paper) range of about 16 (full white) to 112 (full black) and empirically, raising the input to a power of 2 makes it look a little better. Working it into the dithering code is pretty straightforward: find the darkest pixel and set the black level to be able to reproduce that.

#include <cups/raster.h>

#include <iostream>
#include <vector>
#include <array>
#include <utility>
#include <cmath>
#include <algorithm>

using std::clog;
using std::cout;
using std::endl;
using std::vector;
using std::array;


constexpr unsigned char ESC = 0x1b;
constexpr unsigned char GS = 0x1d;

// Write out a std::array of bytes as bytes.  This will form the basis
// of sending data to the printer.
template<size_t N>
std::ostream& operator<<(std::ostream& out, const array<unsigned char, N>& a){
	out.write(reinterpret_cast<const char*>(a.data()), a.size());
	return out;
}

array<unsigned char, 2> binary(uint16_t n){
	return {{static_cast<unsigned char>(n&0xff), static_cast<unsigned char>(n >> 8)}};
}


void printerInitialise(){
	cout << ESC << '\x40';
}

// enter raster mode and set up x and y dimensions
void rasterheader(uint16_t xsize, uint16_t ysize)
{
	// Page 33 of the manual
	// The x size is the number of bytes per row, so the number of pixels
	// is always a multiple of 8
	cout << GS << 'v' << '0' << '\0' << binary((xsize+7)/8) << binary(ysize);
}


void set_heating_time(int time_factor){
	// Page 47 of the manual
	// Everything is default except the heat time
	cout << ESC << 7 << (char)7 << (unsigned char)std::max(3, std::min(255,time_factor)) << '\02';
}

constexpr array<array<int, 5>, 3> diffusion_coefficients = {{
		{{0, 0, 0, 7, 5}},
		{{3, 5, 7, 5, 3}},
		{{1, 3, 5, 3, 1}}
}};
constexpr double diffusion_divisor=42;


double degamma(int p){
	return pow(p/255., 1/2.2);
}

int main(){

	cups_raster_t *ras = cupsRasterOpen(0, CUPS_RASTER_READ);
	cups_page_header2_t header;
	int page = 0;

	while (cupsRasterReadHeader2(ras, &header))
	{
		/* setup this page */
		page ++;
		clog << "PAGE: " << page << " " << header.NumCopies << "\n";
		clog << "BPP: " << header.cupsBitsPerPixel << endl;
		clog << "BitsPerColor: " << header.cupsBitsPerColor << endl;
		clog << "Width: " << header.cupsWidth << endl;
		clog << "Height: " << header.cupsHeight << endl;

		// Input data buffer for one line
		vector<unsigned char> buffer(header.cupsBytesPerLine);
		
		//Error diffusion data
		vector<vector<double>> errors(diffusion_coefficients.size(), vector<double>(buffer.size(), 0.0));

		clog << "Line bytes: " << buffer.size() << endl;
		printerInitialise();

		/* read raster data */
		for (unsigned int y = 0; y < header.cupsHeight; y ++)
		{
			if (cupsRasterReadPixels(ras, buffer.data(), header.cupsBytesPerLine) == 0)
				break;

			
			//Estimate the lowest value pixel in the row
			double low_val=1.0;
			for(int i=0; i < (int)buffer.size(); i++)
				 low_val = std::min(low_val, degamma(buffer[i]) + errors[0][i]);
			//Add some headroom otherwise black areas bleed because it can't go
			//dark enough
			low_val*=0.99;

			//Set the darkness based on the darkest pixel we want

			//Emperical formula for the effect of the timing
			double full_white=16;
			double full_black=16*7;
			set_heating_time(pow(1-low_val,2.0)*(full_black-full_white)+full_white);

			//Print in MSB format, one line at a time
			rasterheader(header.cupsWidth, 1);
			unsigned char current=0;
			int bits=0;

			for(int i=0; i < (int)buffer.size(); i++){
				
				//The actual pixel value with gamma correction
				double pixel = degamma(buffer[i]) + errors[0][i];
				double actual = pixel>(1-low_val)/2 + low_val?1:low_val;
				double error = pixel - actual; //This error is then distributed


				//Diffuse forward the error	
				for(int r=0; r < (int)diffusion_coefficients.size(); r++)
					for(int cc=0; cc < (int)diffusion_coefficients[0].size(); cc++){
						int c = cc - diffusion_coefficients[0].size()/2;
						if(c+i >= 0 && c+i < (int)buffer.size() && diffusion_coefficients[r][cc]){
							errors[r][i+c] += error * diffusion_coefficients[r][cc] / diffusion_divisor;
						}
					}

				current |= (actual!=1)<<(7-bits);
				bits++;
				if(bits == 8){
					cout << current;
					bits = 0;
					current = 0;
				}
			}
			if(bits)
				cout << current;
			
			//Roll the buffer round.
			std::rotate(errors.begin(), errors.begin()+1, errors.end());
			for(auto& p:errors.back())
				p=0;
	
		}

		/* finish this page */
	}
	cout << "\n\n\n\n\n\n";
	cupsRasterClose(ras);
}

And it works!!

Spot the difference! Left: image with enhanced greylevels, right standard image. View image to get the full resolution.

OK, the results aren’t spectacular, but look if you hear a dog talking, you’re impressed that it can talk at all, not disappointed that it can’t talk well.

The enhanced grey level image definitely has some horizontal streaking. I don’t know if that’s due to the printer or the really ad-hoc calibration of grey levels that I did. I should probably limit the rate at which the temperature changes vertically to mitigate that.

Overall I’m really pleased. The finer details are clearer and there are definitely some whiskers which you can make out in the left image which are washed out in the speckle right one. Bare in mind there are optimistically 64 distinct grey levels this printer can produce which means this technique is adding about 6 bits per line of 384 bits.

This also pushes the printer far, far beyond what it was ever supposed to do. The heating time is really a way to reduce print time and/or save on total energy draw, presumably for battery powered chip and pin machines.

I expect there is more fiddling to do, but the next stage is to integrate it into CUPS so I can print the usual way (i.e. using lp of course).

One thought on “Adafruit mini thermal printer, part 1/?: getting better pictures

  1. Pingback: Adafruit mini thermal printer, part 2/?: CUPS and other vessels | Death and the penguin

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s