Modular filmβscanning system built on Raspberry Pi + IMX477 + manual lens + stepper motor, designed with a pipeline approach using:
- Python backend (independent, headlessβfriendly)
- gRPC for communication
- State machine using
transitions - Stepper control for advancing film
- Optional UIs: local screen on the Pi, mobile/remote UI, or keyboardβonly operation
- Poetry for dependency and environment management
neganuki/
β
βββ backend/
β βββ camera/ # Camera control (IMX477 with Picamera2)
β β βββ __init__.py
β β βββ imx477.py
β βββ motor/ # Stepper motor control (28BYJ-48)
β β βββ __init__.py
β β βββ stepper.py
β βββ fsm/ # Finite State Machine (transitions)
β β βββ __init__.py
β β βββ scanner_fsm.py
β β βββ states.yaml
β βββ grpc/ # gRPC service
β β βββ __init__.py
β β βββ server.py
β β βββ scanner.proto
β β βββ codegen.py
β β βββ generated/
β βββ pipeline/ # Core scanning pipeline
β β βββ __init__.py
β β βββ controller.py # Main orchestrator
β β βββ evaluator.py # Frame quality & overlap analysis
β β βββ stitcher.py # Image stitching
β β βββ crop.py # Frame cropping
β βββ utils/ # Helpers
β
βββ client/ # UI clients (web / mobile / terminal)
β
βββ pyproject.toml # Poetry configuration
βββ README.md
curl -sSL https://install.python-poetry.org | python3 -Verify installation:
poetry --versionAt the project root:
poetry installThis creates a virtual environment and installs all dependencies including:
picamera2- IMX477 camera controlrpi-lgpio- GPIO control for Raspberry Piopencv-python- Image processingtransitions- State machinegrpcio&grpcio-tools- gRPC communicationrawpy,tifffile- RAW image handlingloguru- Logging
poetry shellOr run commands inside it without activating:
poetry run python backend/grpc/server.pyExample:
poetry add package-nameFor optional dependencies:
poetry install --extras "camera gpio"For GPIO access, the code imports RPi.GPIO.
On Raspberry Pi 3B with Debian Trixie, the safest option is the Debian package
python3-rpi.gpio, which also exposes RPi.GPIO.
If you prefer rpi-lgpio, note that it depends on lgpio and must not be
installed in the same Python environment as rpi-gpio / RPi.GPIO.
Generate Python stubs from proto files:
poetry run python -m grpc_tools.protoc \
-I./backend/grpc \
--python_out=./backend/grpc/generated \
--grpc_python_out=./backend/grpc/generated \
./backend/grpc/scanner.protoOr use the built-in script:
poetry run generate-protosStart the gRPC server and the MJPEG live preview with a single command:
poetry run neganuki-serverThis starts:
- gRPC server on
0.0.0.0:50051 - MJPEG preview at
http://<pi-ip>:8080/(open in any browser)
Custom ports / options:
poetry run neganuki-server --host 0.0.0.0 --port 50051 --http-port 8080Disable the browser preview:
poetry run neganuki-server --no-previewRun from your PC (not the Pi). Requires SSH access to the Pi.
poetry run python clients/neganuki-terminal/scanner_client.py \
--host 192.168.1.13 \
--action capture --raw \
--copy-to-host \
--copy-user <pi-username> \
--copy-path ~/neganuki/| Option | Description |
|---|---|
--host |
Pi's IP address |
--raw |
Capture RAW TIFF instead of RGB preview |
--copy-to-host |
Pull the file to your PC via scp after capture |
--copy-user |
SSH username on the Pi |
--copy-path |
Local destination folder on your PC |
Omit --raw to capture an RGB frame instead.
The GUI client provides a complete interface with scan controls, live preview, and status monitoring.
Ensure Pillow is installed for image handling:
poetry add Pillowpoetry run python client/neganuki-ui/scanner_gui.pyOr from within the poetry shell:
poetry shell
cd client/neganuki-ui
python scanner_gui.py- Connection Management - Connect to scanner with host/port configuration
- Scan Controls - Start, pause, resume, and stop scanning operations
- Frame Capture - Capture single RGB or RAW frames
- Live Preview - Real-time camera preview when scanner is idle (10 FPS)
- Status Monitoring - Auto-refresh status with frame count and state tracking
- Preview Display - View captured frames and live camera feed
- Status Log - Scrollable log with timestamps and color-coded messages
-
Start the backend server (in one terminal):
poetry run python backend/grpc/server.py
-
Start the GUI client (in another terminal):
poetry run python client/neganuki-ui/scanner_gui.py
-
In the GUI:
- Enter host:
localhost(or Pi IP address if remote) - Enter port:
50051 - Click Connect
- Check Live Preview to see camera feed (when idle)
- Click Start Scan to begin scanning
- Enter host:
Terminal-based menu interface:
poetry run python client/raspberry-pi/interactive_scanner.pyFeatures: Interactive menu, status display, pause/resume control, frame capture
Automation-friendly script:
# Quick scan
poetry run python client/raspberry-pi/simple_scan.py --quick-scan
# Capture test frame
poetry run python client/raspberry-pi/simple_scan.py --capture
# Monitor scan
poetry run python client/raspberry-pi/simple_scan.py --monitorUse the client library in your own scripts:
from client.raspberry_pi.scanner_client import ScannerClient
client = ScannerClient()
client.connect("localhost:50051")
# Start scanning
client.start_scan()
# Monitor progress
for state, frame_count in client.stream_status():
print(f"State: {state}, Frames: {frame_count}")
if state == "finished":
break
client.shutdown()- Raspberry Pi 3B (or newer) running Raspberry Pi OS Bookworm
- IMX477 Camera Module (12MP, HQ Camera)
- 28BYJ-48 Stepper Motor with ULN2003 driver
- Manual lens compatible with IMX477 (C/CS mount)
Default stepper motor pins (configurable):
- IN1: GPIO 17
- IN2: GPIO 18
- IN3: GPIO 27
- IN4: GPIO 22
Add your user to the GPIO group:
sudo usermod -aG gpio $USERThe scanner uses a finite state machine with the following states:
- idle - Waiting to start
- initializing - Setting up camera and motor
- capturing - Taking a photo
- evaluating - Checking frame quality
- stitching - Combining frames
- advancing - Moving film forward
- checking_completion - Deciding if scan is done
- paused - Scan temporarily stopped
- finished - Scan completed successfully
- error - General error state
- camera_error - Camera-specific error (with recovery)
- motor_error - Motor-specific error (with recovery)
start- Begin scanningpause/resume- Pause and resume operationsretry_capture- Retry if frame quality is poorfail- Enter error staterecover_camera/recover_motor- Automatic recovery from errorsabort- Emergency stop
Start the scanning pipeline.
stub.StartCapture(scanner_pb2.CaptureRequest())Get current FSM state and frame count.
status = stub.GetStatus(scanner_pb2.StatusRequest())
print(f"State: {status.state}, Frames: {status.frame_count}")Pause or resume the current scan.
stub.PauseScan(scanner_pb2.PauseRequest())
stub.ResumeScan(scanner_pb2.ResumeRequest())Capture a single frame (bypass FSM).
# Preview frame
response = stub.CaptureFrame(scanner_pb2.FrameCaptureRequest(raw=False))
# RAW frame
response = stub.CaptureFrame(scanner_pb2.FrameCaptureRequest(raw=True))Real-time status updates via server streaming.
for update in stub.StreamStatus(scanner_pb2.StatusRequest()):
print(f"State: {update.state}, Frames: {update.frame_count}")Stop scanning and cleanup resources.
stub.Shutdown(scanner_pb2.ShutdownRequest())Configure in PipelineController:
camera_config = {
'resolution': (4056, 3040), # IMX477 full resolution
}
controller = PipelineController(
output_dir="./output",
camera_config=camera_config,
max_frames=100,
detect_film_end=True
)motor_pins = {
'pins': (17, 18, 27, 22), # GPIO pins
'delay': 0.002 # Step delay in seconds
}
controller = PipelineController(
output_dir="./output",
motor_pins=motor_pins
)Adjust in CaptureEvaluator:
evaluator = CaptureEvaluator(
sharpness_threshold=100.0, # Laplacian variance
brightness_min=30.0, # Min acceptable brightness
brightness_max=225.0, # Max acceptable brightness
)- Sharpness detection - Rejects blurry frames
- Exposure validation - Detects over/underexposed frames
- Retry mechanism - Up to 3 retries per frame
- Dark frame detection - Identifies end of film
- Edge density analysis - Detects blank leader/trailer
- Frame count limit - Prevents runaway scanning
- Camera errors - Automatic reinitialization
- Motor errors - GPIO reset and recovery
- Graceful degradation - Continues when possible
- Feature-based alignment - ORB or SIFT descriptors
- Homography transformation - Accurate frame alignment
- Automatic blending - Seamless mosaic creation
This project uses Conventional Commits for consistent commit messages and automatic versioning.
# Install development dependencies
poetry install --with dev
# Install pre-commit hooks
poetry run pre-commit install
poetry run pre-commit install --hook-type commit-msg# Interactive commit (recommended)
git add .
poetry run cz commit
# Manual commit (validated by pre-commit hook)
git commit -m "feat(camera): add live preview streaming"# Automatic version bump and changelog generation
poetry run cz bump
# Push with tags
git push --follow-tags origin mainFor detailed instructions, see docs/semantic-commits/ and CONTRIBUTING.md.
Useful for users who donβt use Poetry:
poetry export -f requirements.txt --output requirements.txt --without-hashes
- The backend is designed to run headless (no UI required)
- Control everything via gRPC from any client
- The pipeline is modular and extensible
- Built around the
RPi.GPIOAPI so it can run with eitherpython3-rpi.gpioorrpi-lgpio, depending on the target image - Supports both preview (RGB) and RAW (Bayer) capture modes
- State machine configuration in YAML for easy customization
- Automatic error recovery for camera and motor failures
stateDiagram-v2
[*] --> idle
idle --> initializing: start
initializing --> capturing: init_done
capturing --> evaluating: capture_done
evaluating --> capturing: retry_capture
evaluating --> stitching: accept_capture
stitching --> advancing: stitch_done
advancing --> checking_completion: advance_done
checking_completion --> capturing: more_frames
checking_completion --> finished: scan_complete
capturing --> paused: pause
evaluating --> paused: pause
stitching --> paused: pause
advancing --> paused: pause
paused --> capturing: resume
capturing --> camera_error: camera_fail
evaluating --> camera_error: camera_fail
camera_error --> capturing: recover_camera
advancing --> motor_error: motor_fail
motor_error --> advancing: recover_motor
idle --> error: fail
initializing --> error: fail
capturing --> error: fail
evaluating --> error: fail
stitching --> error: fail
advancing --> error: fail
checking_completion --> error: fail
camera_error --> error: fail
motor_error --> error: fail
error --> idle: recover
idle --> finished: abort
capturing --> finished: abort
evaluating --> finished: abort
stitching --> finished: abort
advancing --> finished: abort
paused --> finished: abort
finished --> [*]
error --> [*]
# Check if camera is connected
vcgencmd get_camera
# Test with libcamera
libcamera-hello# Add user to gpio group
sudo usermod -aG gpio $USER
# Reboot required
sudo reboot# Ensure virtual environment is activated
poetry shell
# Reinstall dependencies
poetry install --sync
# Install Raspberry Pi GPIO support for this project
poetry install --extras "gpio"
# Or add it explicitly to Poetry
poetry add rpi-lgpio# Recommended on Debian Trixie / Python 3.13
sudo apt install python3-rpi.gpioThe project imports RPi.GPIO, so python3-rpi.gpio works without code
changes on a real Raspberry Pi.
If you use rpi-lgpio instead:
# rpi-lgpio needs lgpio too
sudo apt install python3-lgpio
pip install rpi-lgpioDo not install rpi-lgpio and rpi-gpio in the same Python environment.
If you run inside a Poetry virtualenv, remember that system packages such as
python3-rpi.gpio are not visible unless that virtualenv is configured to use
system site packages.
# Check if server is running
poetry run python backend/grpc/server.py
# Verify port is not in use
netstat -tuln | grep 50051