Back to blog

Splitting Pentax 17 Film Scans

8 min read

I got into photography and immediately chose friction.

I picked up the Pentax 17, a half-frame film camera. It captures two 17x24 mm photos on a single 35 mm frame.

That means every lab scan comes back as one JPEG with two images stuck together.

At first, manual cropping feels fine.

Then you do it 50 times.

Crop. Save. Repeat. Crop the wrong edge. Save. Repeat again. Miss a few pixels. Notice later. Sigh.

So I built a pipeline with Node.js and Sharp that splits each scan into two frames automatically, then exports them at consistent, high-quality dimensions.

This post is the deep dive. Not just the “here is the code” version.

The Problem

My lab returns each scan as a single JPEG that contains:

Example of a raw Pentax 17 scan showing two frames side by side with a dark divider in the middle

The job is:

  1. Detect the divider.
  2. Crop left and right frames.
  3. Export both frames at uniform dimensions.
  4. Avoid unnecessary cropping and avoid pointless bars.
Note

Letterboxing adds black bars to match a target size. It preserves content but wastes pixels and lowers effective resolution.

The Approach

I went with a two-pass pipeline.

Why two passes?

Because “pick a target size” is not a guess. It is something you can measure.

Pass 1: Analyze

Pass 2: Process

This adapts to scan variation instead of relying on fixed coordinates.

Image Processing Pipeline

1. Convert to grayscale

Divider detection gets easier if the image becomes one channel.

const grayBuffer = await image.clone().grayscale().raw().toBuffer();

Why grayscale?

Sharp’s .grayscale() uses a luminance formula (BT.709):

Y = 0.2126 R + 0.7152 G + 0.0722 B

That matches human perception pretty well, which matters when you are trying to find “the dark thing” in a scan.

.raw() gives you an uncompressed byte buffer:

2. Compute column intensity

The divider runs vertically.

So instead of trying to detect edges in 2D, I compress the image into a 1D signal.

Average brightness per column.

const columnIntensity = Array.from({ length: width }, (_, x) => {
let sum = 0;
for (let y = 0; y < height; y++) {
const pixel = grayBuffer[y * width + x];
if (pixel) sum += pixel;
}
return sum / height;
});

y * width + x converts 2D coordinates into a 1D index.

Why vertical averaging?

3. Smooth the signal

Film grain and scan artifacts make the curve noisy.

So I smooth the intensity profile using a moving average.

const smooth = (array: number[], windowSize: number) => {
const half = Math.floor(windowSize / 2);
return array.map((_, i) => {
const start = Math.max(0, i - half);
const end = Math.min(array.length, i + half + 1);
const slice = array.slice(start, end);
return slice.reduce((a, b) => a + b, 0) / slice.length;
});
};
const smoothed = smooth(columnIntensity, 15);

Why a 15 px window?

After smoothing, the divider shows up as a valley you can trust.

4. Detect divider boundaries

Once I have the smoothed curve, I find the darkest column.

That is the valley center.

Then I expand left and right until brightness rises above a threshold.

const minValue = Math.min(...smoothed);
const threshold = minValue * 1.25;
const minX = smoothed.indexOf(minValue);
let start = minX;
while (start > 0 && smoothed[start] < threshold) start--;
let end = minX;
while (end < smoothed.length && smoothed[end] < threshold) end++;
let dividerStart = Math.max(0, start - 2);
let dividerEnd = Math.min(width, end + 2);

Two details matter here:

If detection collapses into a tiny region, I enforce a minimum divider width:

if (dividerEnd - dividerStart < 10) {
const center = minX;
const half = 5;
dividerStart = Math.max(0, center - half);
dividerEnd = Math.min(width, center + half);
}

This avoids weird “divider is one column wide” failures.

5. Compute crop regions

Once the divider is known, cropping is geometry.

I compute left and right crop starts.

I also avoid assuming the divider is perfectly centered.

const leftCropWidth = Math.min(
dividerStart - cropPadding,
width - dividerEnd - cropPadding,
);
const bleed = 6;
const leftCropStart = Math.max(0, dividerStart - leftCropWidth - bleed);
const rightCropStart = Math.min(width, dividerEnd + bleed);

Why use the narrower side?

Because it guarantees symmetry even if the scan is shifted.

Why the 6 px bleed?

Because “crop too tight” is worse than “include a few dark pixels”.

I also store aspect ratios for the next step:

aspectRatio: cropWidth / cropHeight;

6. Two-pass optimization

Pass 1 collects aspect ratios for every extracted frame.

Pass 2 needs a single target output size.

So I pick a target width and compute height using the median ratio.

const allAspectRatios = analysisResults.flatMap((r) => [
r.leftCrop.aspectRatio,
r.rightCrop.aspectRatio,
]);
const medianAspectRatio = median(allAspectRatios);
const targetSize = {
width: 900,
height: Math.round(900 / medianAspectRatio),
};

Why median?

In practice this landed around 900x1300 px.

To sanity check, I measure how many ratios land near the target:

const tolerance = 0.05;
const perfect = ratios.filter(
(ratio) => Math.abs(ratio / targetAspectRatio - 1) <= tolerance,
).length;

That gave me:

That is exactly the tradeoff I wanted.

7. Extract and resize

Pass 2 does the real work.

Extract each frame and resize into the target box.

await image
.clone()
.extract(leftCrop)
.withMetadata()
.resize(targetSize.width, targetSize.height, {
fit: "cover",
position: "attention",
kernel: sharp.kernel.lanczos3,
})
.jpeg({
quality: 100,
progressive: true,
chromaSubsampling: "4:4:4",
})
.toFile(outputPath);

Key settings:

8. JPEG re-encoding choices

The lab already delivered JPEGs.

So every save is another generation.

I kept re-encoding as gentle as possible:

.jpeg({
quality: 100,
progressive: true,
chromaSubsampling: "4:4:4",
});

9. Parallel processing

Each scan yields two outputs.

So I process both concurrently:

await Promise.all([processLeftFrame(), processRightFrame()]);

It is a small change with an obvious win.

More throughput, less waiting.

Results

On a batch of 112 images (224 frames):

First extracted frame showing enhanced clarity and color correction

Frame 1

Second extracted frame demonstrating improved detail preservation

Frame 2

Edge Cases

Very narrow dividers (10 to 16 px) Frames nearly touch. Minimum-width enforcement prevents collapse.

Very wide dividers (300 to 900 px) Usually double exposures or scan errors. Detected and flagged for review.

Invalid crop geometry Zero or negative crop sizes are skipped automatically.

Conclusion

This started as “I do not want to crop these by hand.”

It turned into a compact image-processing pipeline:

The two-pass approach is the big win.

Analyze first. Then process with confidence.

If you shoot half-frame, this saves hours. It also makes the output consistent enough that you stop thinking about the scans and start thinking about the photos.

Full source code is available as a reference implementation for automated film scan splitting.

Let's Discuss

Questions or feedback? Send me an email.

Last updated on

Back to blog