A Python driver for the Polaroid ProPalette 8000 film recorder -- one of the last and finest CRT-based film recorders ever made.
pp8k lets you expose digital images onto real photographic film (35mm, 4x5, 6x7, 6x8) directly from the command line or from Python. It handles everything: image scaling, film table management, SCSI device communication, and the full exposure protocol.
$ sudo pp8k expose 4 photo.tiff --film PLUSXPAN.FLM
Film table: Plus-X Pan 125 vP2 (35mm)
Type: B&W [Green filter]
Resolution: 4k
Connected: ProPalette 8K (fw 568)
[##############################] 100.0% [GREEN] (2730/2730 lines)
Exposure complete (70.8s total)
- What is this?
- Verified on real hardware
- Install
- Connecting to a device
- Command line
- Python API
- Supported camera backs
- How it works
- Film tables
- Driving the PP8K from a Raspberry Pi
- Project structure
- Documentation
- Requirements
- Acknowledgements
- License
The ProPalette 8000 is a high-resolution film recorder from the late 1990s. It uses a precision CRT to draw images through color filters onto photographic film, producing slides and negatives at up to 8192 x 6710 pixels -- resolution that rivals or exceeds modern digital cameras.
These devices were originally driven by proprietary Windows software that no longer runs on modern systems. pp8k is a from-scratch Linux driver that speaks the ProPalette's SCSI protocol natively, giving these machines a second life.
The SCSI protocol and film table format were reverse-engineered through extensive analysis of the original Polaroid SDK, third-party tools, and real hardware testing. See docs/scsi_protocol.md for the full protocol reference.
pp8k has been driven end-to-end against a real ProPalette 8000 (firmware 568) on two independent host configurations:
| Host | Bus | scsi2pi mode | Result |
|---|---|---|---|
| Lenovo T60 + PCMCIA SCSI | Linux SG_IO via /dev/sg* |
n/a | 4K B&W exposure in 65 s |
| Raspberry Pi 3B + PiSCSI v2.3 B HAT | scsi2pi 6.2.1 (initiator mode) | s2pexec per CDB |
4K B&W exposure in 70.8 s |
The Pi run was an unattended 21-frame test roll on Plus-X 35mm at 4K (PLUSXPAN slot, --filter green, 1-pass B&W). pp8k's internal timer reported 70.8 s/frame; wall-clock 73.0 s/frame. The ~5.8 s delta against the PCMCIA reference is s2pexec subprocess overhead spread over 2730 scanlines — about 2.1 ms/scanline. Acceptable.
Both transports go through the same pp8k.Device.expose() API. Application code is identical between the two; only the pp8k.open() argument changes.
See Driving the PP8K from a Raspberry Pi below for the full bring-up procedure.
pip install git+https://github.com/veroc/pp8k.gitRequires Linux and Python 3.10+. The only Python dependency is Pillow.
For the SCSI side you have two options, both supported by the same code path:
- A regular Linux SCSI host adapter -- any PCI/PCIe card, PCMCIA CardBus card, USB-SCSI bridge, or built-in HBA. The kernel exposes the device as
/dev/sgNand pp8k drives it through SG_IO ioctls. No extra packages. - A Raspberry Pi with a PiSCSI HAT -- bit-bangs the SCSI bus over GPIO. Requires the
scsi2piuserland (Debian package). See the Pi setup section.
pp8k.open() (and the CLI's <device> argument) accepts two forms and dispatches automatically:
| Form | Transport | When to use |
|---|---|---|
Path string, e.g. "/dev/sg2" |
SGIOTransport (kernel SG_IO ioctl) |
Linux machine with a SCSI HBA |
Integer or digit-string, e.g. 4 or "4" |
S2pexecTransport (shells out to s2pexec) |
Raspberry Pi with a PiSCSI HAT and scsi2pi installed |
PP8K's default SCSI ID is 4 (changeable via the front-panel keypad). On the Pi with the HAT, that's the typical argument.
# On a Linux workstation with a SCSI HBA:
sudo pp8k info /dev/sg2
# On a Raspberry Pi with PiSCSI HAT:
sudo pp8k info 4import pp8k
# Pick the form that matches your host:
device = pp8k.open("/dev/sg2") # SCSI HBA path
device = pp8k.open(4) # PiSCSI HAT, SCSI ID 4
device = pp8k.mock() # no hardware -- mock backend for developmentExamples in the rest of the README use the path form for brevity. Substitute the SCSI ID where needed.
# Device identification
sudo pp8k info /dev/sg2
sudo pp8k info 4 # PiSCSI HAT
# Current mode and configuration
sudo pp8k status /dev/sg2
# List the films installed in all 20 device slots (name + aspect)
sudo pp8k slots /dev/sg2
# Reset the device to machine-default state (clears errors, returns to idle)
sudo pp8k reset /dev/sg2
# Upload a film table to a slot (volatile -- wiped on every power cycle;
# reinstall after each cold start. Upload takes ~5-30s.)
sudo pp8k install /dev/sg2 PLUSXPAN.FLM --slot 3
sudo pp8k install /dev/sg2 PLUSXPAN.FLM --slot 3 --force # skip confirm
# Expose an image (B&W/color detected from film table)
sudo pp8k expose /dev/sg2 photo.tiff --film PLUSXPAN.FLM
sudo pp8k expose 4 photo.tiff --film PLUSXPAN.FLM # PiSCSI HAT
# 8K resolution, fill frame (crop to fit)
sudo pp8k expose /dev/sg2 photo.tiff --film EKTA100.FLM --res 8k --transform fill
# Use a pre-installed slot instead of uploading (aspect read from device)
sudo pp8k expose /dev/sg2 photo.tiff --slot 3 # 3-pass color
sudo pp8k expose /dev/sg2 photo.tiff --slot 3 --filter green # 1-pass B&W
# Validate everything without touching the device
sudo pp8k expose /dev/sg2 photo.tiff --film PLUSXPAN.FLM --dry-run# Print film table header and a summary of all 10 LUT sets
pp8k flm show PLUSXPAN.FLM
# Dump one LUT set's curves, sampled every 16 entries
pp8k flm show PLUSXPAN.FLM --set 7
# Emit one LUT set as CSV
pp8k flm show PLUSXPAN.FLM --set 7 --csv > plusxpan-set7.csv
# Structural sanity check (size, decrypt, round-trip, header fields)
pp8k flm validate PLUSXPAN.FLM| Option | Default | Description |
|---|---|---|
--film |
(required, or use --slot) |
Path to .FLM film table file |
--slot |
(required, or use --film) |
Use a film table already installed in a device slot |
--filter |
(none) | Force 1-pass B&W on red/green/blue (slot mode only) |
--res |
4k |
Resolution: 4k or 8k |
--transform |
fit |
fit (letterbox, no crop) or fill (crop to fill frame) |
--background |
black |
Letterbox bar color: black or white |
--rotation |
0 |
Clockwise rotation: 0, 90, 180, 270 |
--dry-run |
Convert image and validate, don't send to device |
import pp8k
# Load a film table
flm = pp8k.load_flm("PLUSXPAN.FLM")
# Connect and expose
with pp8k.open("/dev/sg2") as device: # or pp8k.open(4)
print(device.info) # DeviceInfo(product='ProPalette 8K', firmware=568, ...)
print(device.mode) # ModeState(film_number=4, hres=4096, vres=2730, ..., camera_back='35mm')
device.expose("photo.tiff", flm=flm)
# With all options
with pp8k.open(4) as device:
device.expose(
"photo.tiff",
flm=flm,
resolution="8k",
transform="fill",
background="white",
rotation=90,
on_progress=lambda p: print(f"{p.phase} {p.lines_sent}/{p.lines_total}"),
)
# Mock device for development (no hardware needed)
with pp8k.mock() as device:
device.expose("photo.tiff", flm=flm)flm = pp8k.load_flm("EKTA100.FLM")
flm.name # "Ektachrome 100"
flm.camera_type_name # "35mm"
flm.is_bw # False
flm.lut_sets[7] # LutSet for 4K resolution (set index 7)FilmTable, LutSet, and LutChannel are NamedTuples. Use _replace()
to build modified copies and write them back with save_flm():
import pp8k
flm = pp8k.load_flm("PLUSXPAN.FLM")
# Swap in a new name and scale the set 7 red channel
new_set = flm.lut_sets[7]._replace(scale_r=4)
modified = flm._replace(
name="PLUSXPAN custom",
lut_sets=flm.lut_sets[:7] + (new_set,) + flm.lut_sets[8:],
)
pp8k.save_flm("PLUSXPAN_custom.FLM", modified)
# Or get the encrypted bytes directly (e.g. for in-memory upload)
blob = pp8k.serialize_flm(modified) # 15,639 encrypted bytesRound-trip is byte-perfect: serialize_flm(load_flm(path)) == open(path, "rb").read()
for all 58 original Polaroid film tables tested.
Original Polaroid FLMs follow a strict 2-master authoring convention with two canonical curves: Master A = Set 7 (4K) and Master B = Set 9 (8K). Sets 0/2/4/6 are byte-identical to Set 7, Sets 1/3/5 are ceil(Set 7 / 2), and Set 8 is 2 × Set 9 (clamped to u16). Editing one set in isolation breaks calibration because the firmware loads different curves at different HRES values. Use normalize_masters() to propagate edits cleanly:
fixed = pp8k.normalize_masters(modified) # rewrites derived sets from Set 7 + Set 9
issues = pp8k.validate_masters(flm) # returns [] if the file conformsBy default every exposure runs the device's full per-frame CRT calibration cycle (~30-45 s on fw 568). When you're burning many frames in succession and the CRT state is stable, you can skip it:
device.expose("photo.tiff", flm=flm, calibration_control=pp8k.CAL_NO_CAL)CAL_NO_CAL (3) skips the cycle entirely; the firmware uses its hardwired auto-luma values. Verified clean on developed Plus-X 35mm at fw 568; saves ~47 s per frame. Two other modes are exported:
CAL_NORMAL(0) — default; full per-frame cal + verification.CAL_NO_CHECK(1) — auto-luma without verification (no measurable speedup vsCAL_NORMALon fw 568).
Device.expose() also accepts MODE SELECT parameter overrides as kwargs: ltdrk (0..6), cbal_rgb (each 0..6), lum_rgb (each 50..200), etime_rgb (each 50..200). All are validated client-side and raise ValueError before the SCSI roundtrip if out of range.
pp8k.SCSIError is dispatched into category subclasses based on the ASC range returned by the firmware:
ParameterError— out-of-range parameters (film number, resolution, LTDRK, CBAL, LUM, ETIME, etc.)HardwareError— hardware faults (servo, motor, optics)CalibrationError— calibration failuresFilmTableError— bad or unsupported film table data
Catch the base SCSIError if you don't care about the category, or catch a subclass to handle one class of fault specifically. All four inherit from SCSIError, which inherits from DeviceError.
Frame dimensions are computed at runtime from each FLM's stored aspect
ratio: vres = hres * aspect_h // aspect_w. The values below are what
pp8k.get_frame_dimensions() returns today.
| Camera back | Aspect | 4K (computed) | 8K (computed) |
|---|---|---|---|
| 35mm | 3:2 | 4096 x 2730 | 8192 x 5461 |
| 4x5 | 5:4 | 4096 x 3276 | 8192 x 6553 |
| 6x7 | 11:9 | 4096 x 3351 | 8192 x 6702 |
| 6x8 | 4:3 | 4096 x 3072 | 8192 x 6144 |
Maximum addressable area is 8192 x 6710. All values fit within hardware limits.
The PP8K's V53 video controller works on a 64-pixel grid: HRES (and strictly speaking VRES) must be a multiple of 64, and the firmware pads non-conforming requests up to the next ×64 boundary internally. HRES values of 4096 and 8192 already conform; VRES does not, so the table below lists the largest multiple of 64 at or below the computed VRES. Using these values avoids the firmware-side padding and gives a predictable raster:
| Camera back | 4K (×64) | 8K (×64) |
|---|---|---|
| 35mm | 4096 x 2688 | 8192 x 5440 |
| 4x5 | 4096 x 3264 | 8192 x 6528 |
| 6x7 | 4096 x 3328 | 8192 x 6656 |
| 6x8 | 4096 x 3072 | 8192 x 6144 |
pp8k.get_frame_dimensions() does not snap to 64 today -- it returns
the aspect-correct computed value. Snap manually if your downstream
pipeline cares about the grid alignment.
An exposure goes through these steps:
- Load film table -- decrypt and parse the .FLM file to determine camera type, B&W/color mode, and filter channel
- Convert image -- scale/crop to frame dimensions, split into per-channel scanlines
- Upload film table -- send encrypted FLM data to the device via SCSI
- Configure device -- MODE SELECT sets resolution, film slot, and servo mode
- Start exposure -- the CRT runs a 30-45 second auto-calibration cycle (or pass
calibration_control=pp8k.CAL_NO_CALto skip it; saves ~47 s per frame) - Send scanlines -- 50-line bursts, paced by buffer status polling (~340 KB/s throughput)
- Terminate -- finalize exposure, advance film
For color film: three complete passes (Red, Green, Blue) through the filter wheel. For B&W film: a single pass on the channel specified by the film table (typically Green).
Typical exposure times: ~65 s for 4K B&W on a Linux HBA, ~70 s through a PiSCSI HAT, ~145 s for 4K color.
The PP8K requires .FLM film table files that define the tone mapping curves for each film stock. These encrypted lookup tables control how the CRT intensity translates to film density across all three color channels.
A collection of original Polaroid film tables for various film stocks is available from Phil Pemberton's film recorder archive. Download the film table ZIP, pick the .FLM file that matches your film stock, and pass it to pp8k expose via the --film flag.
This is the recommended setup if you don't have a vintage SCSI host laying around. The PP8K's bus speaks SCSI-1 single-ended over a Centronics-50 connector, and the PiSCSI HAT family bit-bangs that bus over the Pi's GPIO header. With pp8k + the scsi2pi userland on top, a $30 Pi 3B is a complete drop-in replacement for the long-dead Windows host the PP8K was originally bundled with.
The PiSCSI ecosystem has two software stacks: the original akuker/PISCSI project and Uwe Seimet's scsi2pi fork. Both target the same FullSpec v2.x boards. For driving a PP8K, scsi2pi is effectively the only viable option -- and that's not a soft preference.
| Concern | Upstream PiSCSI | scsi2pi |
|---|---|---|
| Initiator-mode focus | No -- mainly target-mode (emulating drives for vintage hosts) | Yes -- forked specifically because upstream "had no interest in exploiting the initiator mode" (Uwe's words) |
| Arbitrary CDB injection | scsidump only handles disk dumps; vendor commands need C++ internal linking |
s2pexec -- pass any CDB and data buffer, get the response back. The PP8K is 90% vendor commands |
| Pre-built packages | Source-only; C++20 build OOMs on 1 GB Pis (Pi 3B, Zero 2 W) and takes ~20 minutes | Pre-built .deb packages for Bullseye, Bookworm, and Trixie |
| Cross-host portability | tied to the PiSCSI HAT | s2pexec --scsi-generic /dev/sg* also drives a regular Linux SCSI HBA -- same CLI on a Pi and on a workstation with a PCMCIA card |
The hardware (any FullSpec PiSCSI v2.x board) is shared between the two stacks. Only the userland differs.
pp8k's S2pexecTransport shells out to s2pexec once per CDB. The per-call subprocess overhead is ~2.1 ms on a Pi 3B -- imperceptible for setup commands and a small fixed tax on the scanline burst loop (about 5.8 s per 4K frame).
- Pi: Raspberry Pi 3B verified; Pi Zero 2 W and Pi 4 are pin-compatible with the same HAT.
- HAT: PiSCSI v2.3 B FullSpec board (the DB-25 variant, e.g. from amigastore.com). The DB-9 / "external" variants also work.
- Cable: DB-25 (HAT side) to Centronics-50 (PP8K side). The PP8K has two Centronics-50 ports wired as a daisy-chain pass-through; either works.
- Termination: the PP8K has internal active termination, software-toggled from the front panel keypad. You must enable it -- the bus is not viable until you do. Default termination state from factory is OFF.
- TERMPWR: the HAT supplies it from the Pi's 5V rail (fused, with a Schottky diode). The PP8K supplies its own end via internal termination once enabled.
The condensed path below is enough to get pp8k info 4 returning a clean INQUIRY response from the PP8K. It assumes you've already provisioned the Pi itself (Pi OS Lite, SSH, hostname, time) -- only the SCSI-specific steps are listed here.
# Pi OS Trixie 64-bit (substitute _bookworm_arm64 / _armhf as appropriate)
cd ~
wget https://www.scsi2pi.net/packages/releases/scsi2pi_6.2.1_trixie_arm64-1.deb
sudo apt install ./scsi2pi_6.2.1_trixie_arm64-1.debThe leading ./ matters. Without it apt treats scsi2pi as a repo name.
sudo tee /etc/profile.d/scsi2pi.sh >/dev/null <<'EOF'
export PATH="$PATH:/opt/scsi2pi/bin"
EOF
source /etc/profile.d/scsi2pi.sh
sudo tee /etc/sudoers.d/scsi2pi-path >/dev/null <<'EOF'
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/scsi2pi/bin"
EOF
sudo chmod 440 /etc/sudoers.d/scsi2pi-path
sudo visudo -cf /etc/sudoers.d/scsi2pi-pathpp8k 0.4.1 and later also probe /opt/scsi2pi/bin/s2pexec directly, so the sudoers tweak is optional -- but every other scsi2pi tool benefits from it too.
sudo apt install -y python3-venv python3-pip
python3 -m venv ~/pp8k-env
~/pp8k-env/bin/pip install git+https://github.com/veroc/pp8k.git
sudo ln -s ~/pp8k-env/bin/pp8k /usr/local/bin/pp8k- Power everything off.
- Seat the HAT on the Pi's GPIO header.
- Connect the DB-25 to Centronics-50 cable between the HAT and either of the PP8K's two Centronics-50 ports.
- Power the PP8K on; wait for the LCD to settle.
- On the PP8K's front keypad:
Escape→Setup→SCSI Terminated?→Yes. While you're there, confirm the SCSI ID (default4). - Power the Pi on. If it fails to come up on the network within a minute, kill power immediately -- that points at a TERMPWR short in the cable.
sudo pp8k info 4Expected output:
Product: ProPalette 8K
Firmware: 568
Revision: '568'
Buffer: 2456 KB
Max H.res: 8192
Max V.res: 6710
Ident: DP2SCSI
If you see this, the driver has end-to-end initiator access to the device. Everything else is software.
If info 4 hangs or returns "no device", in rough order of likelihood:
- Termination not enabled on the PP8K -- re-walk the keypad menu.
- Wrong SCSI ID -- check the PP8K's front panel and pass that integer to
pp8k. - Cable seating -- Centronics-50 connectors are stiff; reseat both ends firmly.
- Cable wiring -- DB-25-to-Centronics-50 cables are not all wired the same. Apple SCSI-2-standard wiring is what the PP8K expects; some budget cables omit GND returns or TERMPWR.
scsi2pi ships an s2p systemd service that runs the target-mode daemon (used to emulate disks for vintage Apple/Atari/Amiga hosts). It defaults to disabled, so it won't auto-start, but if you want belt-and-braces:
# This unit lives in /etc/systemd/system/ (vendor-style), so `mask` won't work.
# disabled is enough; nothing pulls s2p in as a dependency.
systemctl is-enabled s2p # should print "disabled"pp8k only ever invokes s2pexec, never s2p.
pp8k/
├── __init__.py # public API: open(), mock(), load_flm(), Device
├── models.py # NamedTuples: DeviceInfo, ModeState, ExposureProgress
├── errors.py # exception hierarchy
├── constants.py # frame dimensions, camera types, color channels
├── transport.py # SG_IO ioctl + s2pexec subprocess transports
├── commands.py # SCSI command builders
├── scsi.py # real hardware backend
├── mock.py # mock backend for development
├── exposure.py # exposure workflow orchestration
├── imaging.py # image-to-scanline conversion (Pillow)
├── flm.py # .FLM file decryption, parsing, master normalisation
└── cli.py # command-line interface
- SCSI Protocol Reference -- complete command documentation
- CHANGELOG -- per-release detail
- Source code is thoroughly commented with protocol details and hardware notes
- OS: Linux (uses the SCSI Generic
/dev/sgNkernel interface for HBAs, orscsi2pifor the PiSCSI HAT path) - Python: 3.10+
- Dependencies: Pillow (image processing)
- Hardware: any SCSI host adapter (PCI, PCMCIA, USB-SCSI), or a Raspberry Pi 3B / Zero 2 W / 4 with a PiSCSI v2.x FullSpec HAT
- Permissions: root or appropriate udev rules for
/dev/sgNaccess (HBA path) or GPIO (PiSCSI HAT path)
If you find this useful, consider buying me a coffee:
Special thanks to Phil Pemberton for his incredible work documenting and preserving information about Polaroid Digital Palette film recorders. His research into the film table encryption, device protocols, and his collection of original film tables made this project possible.
Thanks to Uwe Seimet for scsi2pi -- the initiator-mode userland that makes the PiSCSI HAT path practical.
LGPL-3.0-or-later