Some years ago we bought a USB flatbed scanner. It got a ton of use, but it took up a lot of room by our computers. As time went on it spent less time hooked up to a computer and more time either in a drawer or resting on (or in) a pile of other barely-used-but-still-useful equipment.
Unfortunately, we still need to scan a lot of bills, receipts, prescription forms, insurance stuff, and all sorts of other things. Since the scanner is now, frankly, a pain in the butt to haul out, blow the dust off, and use, things tend to get left until the last minute. Of course, then there tends to be an enormous backlog of scanning to do.
I tried using a camera for a while. While it did an okay job for the most part, it really depended on lighting and patience to get good enough and consistent enough images. So the old CanoScan LiDE 700F would get put back to work.
This gave me an idea, though – what if it could just sit in a designated spot anywhere in the house and we could scan things without having to make room for it or make room for our computers? I figured that with our fancy electronic computers and the shared electronic cables and radio waves they communicate with each other over, this should be a possibility, right?
I thought about it for a bit and came up with the following requirements:
- It had to work without needing a desktop or laptop,
- It had to use a USB stick to store scans to prevent SD card wear,
- It had to be able to do colour, B&W, and greyscale,
- The DPI had to be adjustable, and
- There needed to be a way to easily get the images off it.
After a little bit of digging, I stumbled across Scanner Access Now Easy (SANE), and a Python package that worked with SANE – python-sane. Those two pieces of software, a Raspberry Pi, and some stitching would give me everything I needed to do what I wanted.
I’d picked up an inexpensive little touchscreen a couple of years ago that plugs directly into the Pi’s GPIO port. It’s not the fastest, it’s not the prettiest, and it’s certainly not the best, but with a 480×320 LCD with a resistive touchscreen, it’s not too shabby at all. You can find these things (and other similar devices) all over the place. The one I used is 3.5″, 480×320, and is one of the devices that uses the LCD-show drivers.
I started out with a Pi 2 I had kicking around but while CPU load and power didn’t seem to be a problem, it wasn’t able to run the scanner smoothly at higher resolutions, and the scanner would stutter so rapidly that it sounded like grinding gears.
My next attempt was going to be with a Pi 3A+ but it only has one USB port and, as I mentioned earlier, I wanted the scans to be saved to a USB stick instead of the SD card. I could’ve used a USB hub, but in my experience, fewer components means less troubleshooting.
So I ended up going with a Pi 4 B+ (2GB). It really feels like overkill, but in contrast to the Pi 2, it scanned very smoothly.
Anyway, so here’s what you’ll need:
- A Pi 4 B+ with Raspberry Pi OS installed and updated and booting to the GUI with an account automatically logged in,
- Your LCD drivers installed and working,
- A SANE-compatible USB scanner,
- A USB stick (the one I’m using is 8GB),
- SANE installed (
sudo apt install sane
), - The python-sane module installed (
sudo apt install python3-sane
), and - Pygame installed
Once you’ve got everything installed and hooked up, start up Python from the command line:
python3
And type the following:
import sane
sane.init()
devices = sane.get_devices()
print(devices)
If, at this point, you see something like:
[('genesys:libusb:001:005', 'Canon', 'LiDE 700F', 'flatbed scanner')]
then SANE has found your scanner and you can continue. If you only see []
then there is a USB connection problem between your Pi and scanner, a power problem, or SANE doesn’t recognize your scanner. Don’t go on unless you can get SANE to find your scanner.
Next, set up your USB stick. Plug it in, remove any existing partitions on it using your favourite GUI or command line tool, then create a single ext4 partition on it.
Now, make a mount point for the USB stick. I made mine at /home/pi/scans.
Try to mount your USB stick:
sudo mount /dev/sda1 /home/pi/scans
If you don’t get an error, you should be good to go. Now edit the /etc/fstab file and add the following line at the end:
/dev/sda1 /home/pi/mnt ext4 defaults,noatime 0 0
Unmount your USB stick (sudo umount /dev/sda1
), and then see if your fstab works by typing:
sudo mount -a
If you get no errors, type df
and see if /dev/sda1 is listed:
If it is, you’re good to go!
Now go into the mount point you created, create a new directory called scans, and set the pi user and group as owners:
cd /home/pi/scans
mkdir scan_share
chown pi scan_share
chgrp pi scan_share
Now, set up Samba:
sudo apt install samba
Once it’s installed, edit the /etc/samba/smb.conf file. Comment out everything in the “Share Definitions” section at the end of the file and add the following:
[scans]
comment = Scanner Share
guest ok = no
browseable = yes
create mask = 0700
directory mask = 0700
read only = no
valid users = pi
path = /home/pi/scans/scan_share
Now, run the following command to give the pi user (or whichever user you want, I’m using pi) a password to use the Samba share you just created:
sudo smbpasswd -a pi
I strongly suggest not using the same password for Samba as you use for the pi account you use to log into your Pi’s console with.
Now reboot your Pi and once it comes back up, you should be able to see your Samba share on the network:
Go into “scans”, enter your username and the password you created with smbpasswd, and you should be greeted with an empty folder. Make sure you can create something in there – if not, check the permissions on your /home/pi/scans/scan_share folder.
So now you’ve confirmed your Pi can talk to your scanner, you’ve created a filesystem on a USB stick and are mounting it at boot, you’ve set up a Samba file share and have successfully logged into it and made sure you have permissions to the share. Time to glue it all together.
To do this, I used Pygame (with Python 3). I’m a huge fan of it because I’ve used it for a lot of different projects, I’m pretty comfortable with it, and there is a lot of documentation out there with no shortage of examples.
I am, however, NOT a programmer. My formative programming years were spent on a Commodore PET 4032 almost 40 years ago, and I still miss programming with line numbers. It seems to work with my setup, though.
It’s not done by any means (I could probably pass variables to a single function to set up the scan options instead of having each option set by its own function, and I need to play with the font sizes a bit more), but one of the nice things about having a site like this is that I can write down everything that’s on the 1397 pieces of scrap paper and sticky notes that I’ve been using to organize and store the information for this project and then come back to it later. So, without further ado, here’s the Python 3 code:
'''
scan-05.py
05: Partial rewrite to use functions instead of my usual giant list of nested loops
04: Now with graphical menus:
- Tap to start
- Select mode (Text, Grey, Colour)
- icon sizes:
- text: 0,125 to 155,197
- grey: 162,125 to 315, 197
- colour: 322,125 to 479,197
- Select DPI (75, 150, 300, 600)
- Select size (letter, full bed length)
- Select Output (PNG, JPG)
- "Another scan with these parameters?"
03: Detect touch and scan document with python-sane
02: Making progress with display graphics
User interface for document scanner (CanoScan LIDE 700F, should work for every SANE-supported USB scanner)
Uses 3.5" resistive touch display (480x320) on Pi 4 to show options/menus
Saves to appropriate folder on attached USB stick to save wear on SD card
*** GREY IS SPELLED 'GRAY' IN THE PYTHON-SANE MODULE. COLOUR IS SPELLED 'COLOR' ***
'''
import pygame, os, time, sys, sane
HEIGHT = 320
WIDTH = 480
RED = 255,0,0
GREEN = 0,255,0
BLUE = 0,0,255
YELLOW = 255,255,0
PURPLE = 255,0,255
BLACK = 0,0,0
WHITE = 255,255,255
DK_GREY = 50,50,50
MED_GREY = 127,127,127
LT_GREY = 200,200,200
BOOT_TXT = 'Starting...'
START_TXT = 'TAP TO START'
SCANNER_CHECK_TXT = 'Checking Scanner'
DPI_75_TXT = '75DPI'
DPI_150_TXT = '150DPI'
DPI_300_TXT = '300DPI'
DPI_600_TXT = '600DPI'
MODE_TEXT_TXT = ' Text '
MODE_GRAY_TXT = ' Grey '
MODE_COLOR_TXT= 'Colour'
SIZE_LETTER_TXT = '8.5x11.0'
SIZE_BED_TXT = '8.5x11.7'
OUTPUT_PNG_TXT = 'Save as PNG (large)'
OUTPUT_JPG_TXT = 'Save as JPG (small)'
SCANNING_TXT = 'Scanning...'
SAVING_TXT = 'Saving...'
ANOTHER_TXT = 'Scan again with these settings?'
ANOTHER_YES_TXT = ' YES '
ANOTHER_NO_TXT = ' NO '
ERR_NOSCANNER_TXT = 'NO SCANNER FOUND'
ERR_NOFILE_TXT = 'ERROR WRITING TO FILE'
go_on = True
scan_again = True
pygame.init()
pygame.display.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT), pygame.FULLSCREEN)
screen.fill(BLACK)
huge_font = pygame.font.SysFont('freemono', 100)
big_font = pygame.font.SysFont('freemono', 75)
med_font = pygame.font.SysFont('freemono', 45)
small_font = pygame.font.SysFont('freemono', 25)
# *****FUNCTIONS START HERE*****
def SetMode():
screen.fill(BLACK)
pygame.display.flip()
mode_text_obj = med_font.render(MODE_TEXT_TXT, True, BLACK, WHITE)
mode_text_rect = mode_text_obj.get_rect()
mode_text_rect.midleft = (0,160)
screen.blit(mode_text_obj, mode_text_rect)
mode_gray_obj = med_font.render(MODE_GRAY_TXT, True, LT_GREY, DK_GREY)
mode_gray_rect = mode_gray_obj.get_rect()
mode_gray_rect.center = (240,160)
screen.blit(mode_gray_obj, mode_gray_rect)
mode_color_obj = med_font.render(MODE_COLOR_TXT, True, RED, BLUE)
mode_color_rect = mode_color_obj.get_rect()
mode_color_rect.midright = (480,160)
screen.blit(mode_color_obj, mode_color_rect)
pygame.display.flip()
print("E")
pygame.event.clear()
go_on = False
while go_on == False:
for event in pygame.event.get():
if event.type == pygame.KEYDOWN:
if event.unicode == 'z':
pygame.quit()
sys.exit
if event.type == pygame.MOUSEBUTTONUP:
pos = pygame.mouse.get_pos()
pygame.event.clear()
if (pos[0] > 0 and pos[0] < 155 and pos[1] > 125 and pos[1] < 197):
print("TEXT")
scanner.mode = 'Lineart'
go_on = True
elif (pos[0] > 162 and pos[0] < 315 and pos[1] > 125 and pos[1] < 197):
print("GREY")
scanner.mode = 'Gray'
go_on = True
elif (pos[0] > 322 and pos[0] < 479 and pos[1] > 125 and pos[1] < 197):
print("COLOUR")
scanner.mode = 'Color'
go_on = True
else:
print("AREA OUTSIDE MODE BUTTONS PRESSED")
go_on = False
go_on = True
return()
def SetDPI():
# Options are 75, 150, 300, 600, put them at top left, bottom left, top right, bottom right
screen.fill(BLACK)
pygame.display.flip()
DPI_75_obj = med_font.render(DPI_75_TXT, True, WHITE, BLACK)
DPI_75_rect = DPI_75_obj.get_rect()
DPI_75_rect.topleft = (0,0)
screen.blit(DPI_75_obj, DPI_75_rect)
DPI_150_obj = med_font.render(DPI_150_TXT, True, WHITE, BLACK)
DPI_150_rect = DPI_150_obj.get_rect()
DPI_150_rect.bottomleft = (0,320)
screen.blit(DPI_150_obj, DPI_150_rect)
DPI_300_obj = med_font.render(DPI_300_TXT, True, WHITE, BLACK)
DPI_300_rect = DPI_300_obj.get_rect()
DPI_300_rect.topright = (480,0)
screen.blit(DPI_300_obj, DPI_300_rect)
DPI_600_obj = med_font.render(DPI_600_TXT, True, WHITE, BLACK)
DPI_600_rect = DPI_600_obj.get_rect()
DPI_600_rect.bottomright = (480,320)
screen.blit(DPI_600_obj, DPI_600_rect)
pygame.display.flip()
print("E")
pygame.event.clear()
go_on = False
while go_on == False:
for event in pygame.event.get():
if event.type == pygame.KEYDOWN:
if event.unicode == 'z':
pygame.quit()
sys.exit
if event.type == pygame.MOUSEBUTTONUP:
pos = pygame.mouse.get_pos()
pygame.event.clear()
if (pos[0] > 0 and pos[0] < 240 and pos[1] > 0 and pos[1] < 160):
print("75DPI")
scanner.resolution = 75
go_on = True
elif (pos[0] > 0 and pos[0] < 240 and pos[1] > 161 and pos[1] < 320):
print("150DPI")
scanner.resolution = 150
go_on = True
elif (pos[0] > 241 and pos[0] < 479 and pos[1] > 0 and pos[1] < 160):
print("300DPI")
scanner.resolution = 300
go_on = True
elif (pos[0] > 241 and pos[0] < 479 and pos[1] > 161 and pos[1] < 320):
print("600DPI")
scanner.resolution = 600
go_on = True
else:
print("AREA OUTSIDE MODE BUTTONS PRESSED")
go_on = False
time.sleep(1)
go_on = True
return()
def SetSize():
# Options are Letter (8.5x11") and full bed (8.5x11.7)
# br_x = 216.0699920654297 for both
# br_y = 279.4 for Letter and 297 for full bed
screen.fill(BLACK)
pygame.display.flip()
SIZE_LETTER_obj = med_font.render(SIZE_LETTER_TXT, True, WHITE, BLACK)
SIZE_LETTER_rect = SIZE_LETTER_obj.get_rect()
SIZE_LETTER_rect.midleft = (0,160)
screen.blit(SIZE_LETTER_obj, SIZE_LETTER_rect)
SIZE_BED_obj = med_font.render(SIZE_BED_TXT, True, WHITE, BLACK)
SIZE_BED_rect = SIZE_BED_obj.get_rect()
SIZE_BED_rect.midright = (480,160)
screen.blit(SIZE_BED_obj, SIZE_BED_rect)
pygame.display.flip()
pygame.event.clear()
go_on = False
while go_on == False:
for event in pygame.event.get():
if event.type == pygame.KEYDOWN:
if event.unicode == 'z':
pygame.quit()
sys.exit
if event.type == pygame.MOUSEBUTTONUP:
pos = pygame.mouse.get_pos()
pygame.event.clear()
if (pos[0] > 0 and pos[0] < 240 and pos[1] > 0 and pos[1] < 320):
print("8.5x11")
scanner.br_x = 216.0699920654297
scanner.br_y = 279.4
go_on = True
elif (pos[0] > 240 and pos[0] < 480 and pos[1] > 0 and pos[1] < 320):
print("8.5x11.7")
scanner.br_x = 216.0699920654297
scanner.br_y = 297.0
go_on = True
else:
print("AREA OUTSIDE MODE BUTTONS PRESSED")
go_on = False
time.sleep(1)
go_on = True
return()
def SetFileType():
screen.fill(BLACK)
pygame.display.flip()
OUTPUT_PNG_obj = med_font.render(OUTPUT_PNG_TXT, True, WHITE, BLACK)
OUTPUT_PNG_rect = OUTPUT_PNG_obj.get_rect()
OUTPUT_PNG_rect.topleft = (0,0)
screen.blit(OUTPUT_PNG_obj, OUTPUT_PNG_rect)
OUTPUT_JPG_obj = med_font.render(OUTPUT_JPG_TXT, True, WHITE, BLACK)
OUTPUT_JPG_rect = OUTPUT_JPG_obj.get_rect()
OUTPUT_JPG_rect.bottomleft = (0,320)
screen.blit(OUTPUT_JPG_obj, OUTPUT_JPG_rect)
pygame.display.flip()
pygame.event.clear()
go_on = False
while go_on == False:
for event in pygame.event.get():
if event.type == pygame.KEYDOWN:
if event.unicode == 'z':
pygame.quit()
sys.exit
if event.type == pygame.MOUSEBUTTONUP:
pos = pygame.mouse.get_pos()
pygame.event.clear()
if (pos[0] > 0 and pos[0] < 480 and pos[1] > 0 and pos[1] < 160):
print("PNG SCAN")
screen.fill(BLACK)
SCANNING_obj = med_font.render(SCANNING_TXT, True, WHITE, BLACK)
SCANNING_rect = SCANNING_obj.get_rect()
SCANNING_rect.center = (240,160)
screen.blit(SCANNING_obj, SCANNING_rect)
pygame.display.flip()
scanner.start()
im = scanner.snap()
# Now save the scanned image
screen.fill(BLACK)
SAVING_obj = med_font.render(SAVING_TXT, True, WHITE, BLACK)
SAVING_rect = SAVING_obj.get_rect()
SAVING_rect.center = (240,160)
screen.blit(SAVING_obj, SAVING_rect)
pygame.display.flip()
filename = '/home/pi/scans/scan_share/' + time.strftime('%Y-%m-%d_%H%M-%S') + '.png'
im.save(filename)
time.sleep(0.5)
go_on = True
elif (pos[0] > 0 and pos[0] < 480 and pos[1] > 160 and pos[1] < 320):
print("JPG SCAN")
screen.fill(BLACK)
SCANNING_obj = med_font.render(SCANNING_TXT, True, WHITE, BLACK)
SCANNING_rect = SCANNING_obj.get_rect()
SCANNING_rect.center = (240,160)
screen.blit(SCANNING_obj, SCANNING_rect)
pygame.display.flip()
scanner.start()
im = scanner.snap()
# Now save the scanned image
screen.fill(BLACK)
SAVING_obj = med_font.render(SAVING_TXT, True, WHITE, BLACK)
SAVING_rect = SAVING_obj.get_rect()
SAVING_rect.center = (240,160)
screen.blit(SAVING_obj, SAVING_rect)
pygame.display.flip()
filename = '/home/pi/scans/scan_share/' + time.strftime('%Y-%m-%d_%H%M-%S') + '.jpg'
im.save(filename)
print(filename)
go_on = True
else:
print("AREA OUTSIDE MODE BUTTONS PRESSED")
go_on = False
time.sleep(1)
go_on = True
return()
# *****FUNCTIONS END HERE*****
boot_text_obj = med_font.render(BOOT_TXT, True, WHITE, BLACK)
boot_text_rect = boot_text_obj.get_rect()
boot_text_rect.center = (240, 160)
screen.blit(boot_text_obj, boot_text_rect)
pygame.display.flip()
time.sleep(2)
# Main loop here
while True:
screen.fill(BLACK)
start_text_obj = med_font.render('TAP TO START', True, MED_GREY, BLACK)
start_text_rect = start_text_obj.get_rect()
start_text_rect.center = (240, 160)
screen.blit(start_text_obj, start_text_rect)
pygame.display.flip()
for event in pygame.event.get():
if event.type == pygame.KEYDOWN:
if event.unicode == 'z':
pygame.quit()
sys.exit()
if event.type == pygame.MOUSEBUTTONDOWN:
screen.fill(BLACK)
scanner_check_text_obj = med_font.render(SCANNER_CHECK_TXT, True, LT_GREY, BLACK)
scanner_check_text_rect = scanner_check_text_obj.get_rect()
scanner_check_text_rect.center = (240,160)
screen.blit(scanner_check_text_obj, scanner_check_text_rect)
pygame.display.flip()
pygame.event.clear()
sane.init()
devices = sane.get_devices()
print("A")
if (devices == []):
go_on = False
screen.fill(BLACK)
err_text_obj = small_font.render('SCANNER NOT FOUND', True, RED, BLACK)
err_text_rect = err_text_obj.get_rect()
err_text_rect.center = (240,160)
screen.blit(err_text_obj, err_text_rect)
pygame.display.flip()
time.sleep(5)
print("B")
else:
go_on = True
print("C")
if (go_on == True):
print("D")
# If we're here, open the scanner ask for and set the mode ('Lineart', 'Gray', 'Color')
scanner = sane.open(devices[0][0])
SetMode()
print(scanner.mode)
# If we're here, ask for and set the DPI (75, 150, 300, 600)
SetDPI()
print("E")
print(scanner.resolution)
# If we're here, ask for and set the scan size (Letter, Full Bed)
SetSize()
print("F")
print(scanner.area)
# If we're here, ask for and set the output format (JPG, PNG), do the scan, and
# save the file.
SetFileType()
print("G")
#print(filename)
#If we're here, check to see if another scan should be done with same settings
# ANOTHER_TXT, ANOTHER_YES_TXT, ANOTHER_NO_TXT
while scan_again == True:
screen.fill(BLACK)
ANOTHER_obj = small_font.render(ANOTHER_TXT, True, WHITE, BLACK)
ANOTHER_rect = ANOTHER_obj.get_rect()
ANOTHER_rect.midtop = (240,0)
screen.blit(ANOTHER_obj, ANOTHER_rect)
ANOTHER_YES_obj = big_font.render(ANOTHER_YES_TXT, True, BLACK, WHITE)
ANOTHER_YES_rect = ANOTHER_YES_obj.get_rect()
ANOTHER_YES_rect.bottomleft = (0,320)
screen.blit(ANOTHER_YES_obj, ANOTHER_YES_rect)
ANOTHER_NO_obj = big_font.render(ANOTHER_NO_TXT, True, BLACK, WHITE)
ANOTHER_NO_rect = ANOTHER_NO_obj.get_rect()
ANOTHER_NO_rect.bottomright = (480,320)
screen.blit(ANOTHER_NO_obj, ANOTHER_NO_rect)
pygame.display.flip()
#pygame.event.clear()
for event in pygame.event.get():
if event.type == pygame.KEYDOWN:
if event.unicode == 'z':
print("H")
pygame.quit()
sys.exit
if event.type == pygame.MOUSEBUTTONUP:
print("I")
pos = pygame.mouse.get_pos()
pygame.event.clear()
if (pos[0] > 0 and pos[0] < 240 and pos[1] > 160 and pos[1] < 320):
print("Scanning again with same settings")
SetFileType()
scan_again = True
elif (pos[0] > 240 and pos[0] < 480 and pos[1] > 160 and pos[1] < 320):
print("Don't scan again, close scanner and go back to start")
scanner.close()
scan_again = False
else:
print("AREA OUTSIDE MODE BUTTONS PRESSED")
scan_again = True
All of the print statements are there to leave info on the console for troubleshooting purposes. You can also exit out of the program at any screen by hitting the ‘z’ key (if you have a keyboard connected).
Here’s how it looks while it’s running (note that you have to be booted into the GUI for it to work):
And that’s pretty much what it does at this point. You can probably tell I’m not much of a UI designer, either.
I also needed a different case for this to work, because the case that came with the display only fit the Pi B+/2/3 and not the Pi 4. I came up with a pretty simple case that I’m pleased with, and the display socket fits onto the GPIO pretty much perfectly. I will probably change it again at some point but for now the display is shielded from the heat the Pi throws off, and the Pi case is vented so it doesn’t get too hot in the first place now, either. You can find the case STL files on Thingiverse.
Here it is with the Pi fastened to the lid of the scanner with part of a sticky silicone pad (the tape will be replaced with Command hooks shortly):
To get it to run automatically when the GUI loads, you need to do two things. First, make sure that you configure the Pi to boot to the GUI and log in automatically (you can set that with sudo raspi-config
). Second, edit /etc/xdg/lxsession/LXDE-pi/autostart
and add the following line at the end:
@python3 /home/pi/scan-05.py
(or whatever you called your file)
Save, reboot, and the program should start automatically when the Pi loads and logs into the GUI.
I haven’t played with overlay filesystems much, but I enabled it on the Pi with the idea that the whole thing can be turned on and off with just the switch on the power cord (or by pulling the plug) and not damage the filesystem on the SD card. So far it seems to be doing the trick but I haven’t really put it through the gears yet.
So yeah… that’s pretty much it. Still a work in progress, not the prettiest thing, but it seems to work. And now anyone in the house can scan whenever they want to, and we don’t need to worry about finding the scanner and hoping it works again at the end of November.
Thanks to the following people and/or resources:
- Running something when the GUI boots: Arnold Chan, https://raspberry-projects.com/pi/pi-operating-systems/raspbian/auto-running-programs-gui
- Setting the SANE scan resolution: various, https://python.hotexamples.com/examples/sane/-/get_devices/python-get_devices-function-examples.html
If you’ve read this far, you’re either one of those bots that scrapes content, or a human that’s pretty bored. This was a fun project, though, and I hope there’s something in here you can use!