NOTE: You can find the full code here.
I'm currently looking at old source code. More specifically, I've been playing around with Sprite and trying to get it to compile on modern hardware. Sprite was a UNIX-like experimental distributed operating system developed between 1984 and 1992 at the University of California Berkeley. Sprite was developed with the intent to create a more “network aware,” while keeping it invisible to the user. The primary innovation was a new network file system that utilized local client-side caching to enhance performance. Once a file is opened and some initial reads are completed, the network is accessed only on-demand, with most user actions interacting with the cache. Similar mechanisms allow remote devices to be integrated into the local computer's environment, enabling network printing and other similar functions.
Regardless, there's a directory in the source tree called docs/pictures. The README in the directory states:
This directory contains images of some of the members of the Sprite project. These images are in FaceSaver format and may be displayed with
xloadimage
.
I've never heard of anything called FaceSaver before. A quick Google search comes up with this page on the file format wiki, which is interesting. Apparently these files are rare! Neat-o, now to read them.
The file format, being invented in 1987 and not updated (or even really mentioned) since the mid-90s, unsurprisingly doesn't have great support. Attempts to build xloadimage, which is old enough to pre-date the Xorg project's use of Autotools and appears to have been relatively untouched for just as long as the FaceSaver format, were unsuccessful. Neither of the applications the Just Solve wiki suggests, NetBPM and Konverter, could handle them, although this might be a build issue on my side.
I really wanted to see inside these photos, so I found a spec
and implemented a FaceSaver to PPM converter in python, aptly named face2ppm
.
Implementation
FaceSaver files are structured with personal data followed by image data. The format includes a header with various fields and the image data encoded in a hexified format. Our goal is to parse these files and convert the image data into a standard PPM (Portable Pixmap) format. The header is in a format like the one below, following by two newlines:
FirstName:
LastName:
E-mail:
Telephone:
Company:
Address1:
Address2:
CityStateZip:
Date:
PicData: Actual data: width - height - bits/pixel
Image: Should be transformed to: width - height - bits/pixel
Most of this data we don't care about. I can't imagine why it was included in
the format, as it seems that it would be better to maintain an index of every
FaceSaver file and it's associated metadata, instead of have instead of looping
and parsing every file if you wanted to get the head shot of "Mary Baker".
Nevertheless, we only care about PicData
and Image
. Let's write a parser for
the header.
We'll loop through every line in the header, splitting on colons, trimming the
whitespace off the string, and then doing further parsing if we need. This is
needed in the case of PicData
and Image
, which are tuples of width, height,
and bits per pixel.
class ImageShape:
def __init__(self, shape_string):
parts = shape_string.split(' ')
self.width = int(parts[0])
self.height = int(parts[1])
self.density = int(parts[2])
def __str__(self):
return f"width={self.width}, height={self.height}, density={self.density} bpp"
def parse_header(header):
# Split the header into lines
lines = header.strip().split('\n')
parsed_data = {}
for line in lines:
key, value = line.split(': ', 1)
key = key.strip()
value = value.strip()
# Check if we need to do further parsing.
if key == 'PicData' or key == 'Image':
parsed_data[key] = ImageShape(value)
else:
parsed_data[key] = value
return parsed_data
Before we can use this function though, we need to read in the file, extract the header and data (which we can do because they're separated with two newlines), and clean up the data so we can turn it into a byte sequence.
# Read the file.
filename = sys.argv[1]
image_data = None
with open(filename, 'r') as f:
image_data = f.read()
# Do some header parsing.
sections = image_data.split('\n\n')
header = sections[0]
image = sections[1]
header = parse_header(header)
data_shape = header['PicData']
image_shape = header['Image']
image = image.replace('\n', '')
image = bytes.fromhex(image)
After that, we have our image dimensions as well as the raw image bytes. Let's go ahead and write a routine for converting that into a 2D array of colors so we can dump it to disk later.
def bytes_to_rgb_array(byte_sequence, data_shape):
width = data_shape.width
height = data_shape.height
bits_per_pixel = data_shape.density
# Do some checks.
if bits_per_pixel != 8:
raise ValueError(f"We only support 8 bits per pixel, not {bits_per_pixel}.")
if len(byte_sequence) != width * height:
raise ValueError(f"Image data is not as reported, wanted {width * height} " +
"got {len(byte_sequence)}.")
# Move stuff around.
byte_array = list(byte_sequence)[::-1]
byte_array = np.array(byte_array, dtype=np.uint8).reshape((height, width))
rgb_array = np.zeros((height, width, 3), dtype=np.uint8)
# Marshall into colors.
for y in range(height):
for x in range(width):
pixel_value = byte_array[y, x]
rgb_array[y, x] = (pixel_value, pixel_value, pixel_value)
rgb_list_of_lists = [[tuple(rgb_array[y, x]) for x in range(width)] for y in range(height)]
return rgb_list_of_lists
It looks a little complicated, but only after the Move stuff
around. We first
convert byte sequence into an array so NumPy can deal with it, then reverse it
with [::-1]
. This is necessary because the FaceSaver file format specifies
that the image pixels are stored in scanlines from bottom to top, so reversing
the list corrects the order to top to bottom.
Then, the reversed byte_array
is converted to a NumPy array and reshaped to
the specified height and width. This step creates a 2D array representing the
image, but we still need to exact colors. We make space for the colors by
calling np.zeros
to create a new array, then looping over byte_array
,
populating rgb_array
as we go. The nested loop iterates through each pixel in
the byte_array. For each pixel, it retrieves the grayscale pixel_value and sets
the corresponding pixel in rgb_array
to a tuple (pixel_value, pixel_value, pixel_value)
, effectively converting the grayscale image to an RGB image where
R, G, and B values are equal.
Finally, we convert convert the rgb_array
NumPy array into a list of lists of
tuples. Each element in the list is a tuple (R, G, B)
representing the RGB
values of a pixel. We're ready to dump to a PPM file!
# Read the image data to color values.
color_data = bytes_to_rgb_array(image, data_shape)
# Output to PPM.
with open(filename + '.ppm', 'w') as f:
# Write the PPM header.
f.write(f'P3\n{data_shape.width} {data_shape.height}\n255\n')
# Write the pixel data.
for row in color_data:
for color in row:
f.write(f'{color[0]} {color[1]} {color[2]} ')
f.write('\n')
And that's it, we now have a tool which can convert FaceSaver images to PPM files; let's go ahead and test it with some sample FaceSaver files from the Sprite project.
$ tree .
.
├── jhh
├── mgbaker
├── ouster
└── shirriff
$ find . ! -name "*.*" -exec python face2ppm.py {} ;
$ tree .
.
├── jhh
├── jhh.ppm
├── mgbaker
├── mgbaker.ppm
├── ouster
├── ouster.ppm
├── shirriff
└── shirriff.ppm
<div style="display: flex; justify-content: center; align-items: center; margin-left: auto; margin-right: auto; gap: 1.5rem;"> <img src="/mgbaker.png" alt="A woman with bangs beaming into the camera."> <img src="/jhh.png" alt="A man with glasses looking into camera, head tilted slightly."> <img src="/ouster.png" alt="A man with parted hair looking directly into the camera, smiling."> <img src="/shirriff.png" alt="A man in a patterned shirt, smiling and wearing glasses."> </div>
The Full Code
The full code can be found here, and a shorter version can be found below.
# Utility for converting FaceSaver files into PPM format.
#
# Spec: https://netghost.narod.ru/gff/vendspec/face/face.txt
import sys
import numpy as np
def usage():
print("usage: face2ppm [file]", file=sys.stderr)
class ImageShape:
def __init__(self, shape_string):
parts = shape_string.split(' ')
self.width = int(parts[0])
self.height = int(parts[1])
self.density = int(parts[2])
def __str__(self):
return f"width={self.width}, height={self.height}, density={self.density} bpp"
def parse_header(header):
# Split the header into lines
lines = header.strip().split('\n')
# Create an empty dictionary to store the parsed data
parsed_data = {}
# Iterate through each line
for line in lines:
# Split each line into key and value
key, value = line.split(': ', 1)
# Add the key-value pair to the dictionary
parsed_data[key.strip()] = value.strip()
return parsed_data
def bytes_to_rgb_array(byte_sequence, data_shape):
width = data_shape.width
height = data_shape.height
bits_per_pixel = data_shape.density
# Do some checks.
if bits_per_pixel != 8:
raise ValueError(f"We only support 8 bits per pixel, not {bits_per_pixel}.")
if len(byte_sequence) != width * height:
raise ValueError(f"Image data is not as reported, wanted {width * height} " +
"got {len(byte_sequence)}.")
# Move stuff around.
byte_array = list(byte_sequence)[::-1]
byte_array = np.array(byte_array, dtype=np.uint8).reshape((height, width))
rgb_array = np.zeros((height, width, 3), dtype=np.uint8)
# Marshall into colors.
for y in range(height):
for x in range(width):
pixel_value = byte_array[y, x]
rgb_array[y, x] = (pixel_value, pixel_value, pixel_value)
rgb_list_of_lists = [[tuple(rgb_array[y, x]) for x in range(width)] for y in range(height)]
return rgb_list_of_lists
def main():
if (len(sys.argv) != 2):
usage()
exit(1)
# Read the file.
filename = sys.argv[1]
image_data = None
with open(filename, 'r') as f:
image_data = f.read()
# Do some header parsing.
sections = image_data.split('\n\n')
header = sections[0]
image = sections[1]
header = parse_header(header)
data_shape = ImageShape(header['PicData'])
image_shape = ImageShape(header['Image'])
image = image.replace('\n', '')
# Read the image data to color values.
color_data = bytes_to_rgb_array(bytes.fromhex(image), data_shape)
# Output to PPM.
with open(filename + '.ppm', 'w') as f:
# Write the PPM header
f.write(f'P3\n{data_shape.width} {data_shape.height}\n255\n')
# Write the pixel data
for row in color_data:
for color in row:
f.write(f'{color[0]} {color[1]} {color[2]} ')
f.write('\n')
if __name__ == '__main__':
main()
Note
I didn't implement the Image:
part of the header, because in my experience
every file I've tried this utility on has worked fine, with no stretching. I
don't need this functionality, so I'm not going to implement it.