an eink camera running on an rpi zero 2 w

feat: add dither

dunkirk.sh df911e4c d44dd09a

verified
+6 -1
setup.sh
··· 39 39 git pull 40 40 41 41 cp /home/ink/inky/src/camera_server.py /home/ink/ && chown ink:ink /home/ink/camera_server.py && chmod +x /home/ink/camera_server.py 42 + cp /home/ink/inky/src/eink-4gray.png /home/ink/ && chown ink:ink /home/ink/eink-4gray.png 42 43 43 44 # Just restart the service since it's an update 44 45 echo "Restarting camera service..." ··· 51 52 # Update system packages and install dependencies 52 53 echo "Updating package lists and installing dependencies..." 53 54 apt update 54 - apt install -y python3-picamera2 python3-websockets python3-rpi.gpio git 55 + apt install -y python3-picamera2 python3-websockets python3-rpi.gpio git imagemagick 55 56 56 57 # Create directory for storing photos 57 58 echo "Creating photos directory..." ··· 67 68 cp /home/ink/inky/src/camera_server.py /home/ink/ 68 69 chown ink:ink /home/ink/camera_server.py 69 70 chmod +x /home/ink/camera_server.py 71 + 72 + # copy dither palate 73 + cp /home/ink/inky/src/eink-4gray.png /home/ink/ 74 + chown ink:ink /home/ink/eink-4gray.png 70 75 71 76 # Copy and set up systemd service 72 77 echo "Setting up systemd service..."
+51 -30
src/camera_server.py
··· 10 10 import websockets 11 11 import asyncio 12 12 import json 13 - from PIL import Image 14 13 15 14 # Setup logging 16 15 logger = logging.getLogger('camera_server') ··· 28 27 PHOTO_DIR = "/home/ink/photos" 29 28 WEB_PORT = 80 30 29 WS_PORT = 8765 31 - PHOTO_RESOLUTION = (2592, 1944) 30 + PHOTO_RESOLUTION = (1280, 960) 32 31 CAMERA_SETTLE_TIME = 1 33 32 DEBOUNCE_DELAY = 0.2 34 33 POLL_INTERVAL = 0.01 ··· 61 60 body {{ font-family: Arial; max-width: 800px; margin: 0 auto; padding: 20px; }} 62 61 h1 {{ text-align: center; }} 63 62 .gallery {{ display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }} 64 - .photo {{ border: 1px solid #ddd; padding: 5px; animation: fadeIn 0.1s; flex: 0 1 200px; }} 65 - .photo img {{ width: 100%; height: auto; }} 63 + .photo {{ border: 1px solid #ddd; padding: 5px; animation: fadeIn 0.1s; flex: 0 1 200px; position: relative; }} 64 + .photo img {{ width: 100%; height: auto; transition: opacity 0.3s; }} 65 + .photo .colored-img {{ position: absolute; top: 5px; left: 5px; opacity: 0; pointer-events: none; }} 66 + .photo:hover .dithered-img {{ opacity: 0; }} 67 + .photo:hover .colored-img {{ opacity: 1; }} 66 68 .photo .actions {{ text-align: center; margin-top: 5px; }} 67 69 .photo .actions a {{ margin: 0 5px; }} 68 70 @keyframes fadeIn {{ from {{ opacity: 0; }} to {{ opacity: 1; }} }} ··· 105 107 noPhotosMsg.remove(); 106 108 }} 107 109 108 - const photoDiv = document.createElement('div'); 109 - photoDiv.className = 'photo'; 110 - photoDiv.id = `photo-${{filename}}`; 110 + const originalFilename = filename.replace('dithered_', ''); 111 + const isDithered = filename.startsWith('dithered_'); 111 112 112 - photoDiv.innerHTML = ` 113 - <img src="/${{filename}}" alt="${{timestamp}}"> 114 - <div class="actions"> 115 - <a href="/${{filename}}" download>Download</a> 116 - <a href="#" onclick="deletePhoto('${{filename}}'); return false;">Delete</a> 117 - </div> 118 - `; 113 + if (isDithered) {{ 114 + const photoDiv = document.createElement('div'); 115 + photoDiv.className = 'photo'; 116 + photoDiv.id = `photo-${{filename}}`; 117 + 118 + photoDiv.innerHTML = ` 119 + <img class="dithered-img" src="/${{filename}}" alt="${{timestamp}}"> 120 + <img class="colored-img" src="/${{originalFilename}}" alt="${{timestamp}}"> 121 + <div class="actions"> 122 + <a href="/${{originalFilename}}" download>Download Color</a> 123 + <a href="/${{filename}}" download>Download Dithered</a> 124 + <a href="#" onclick="deletePhoto('${{filename}}', '${{originalFilename}}'); return false;">Delete</a> 125 + </div> 126 + `; 119 127 120 - gallery.insertBefore(photoDiv, gallery.firstChild); 128 + gallery.insertBefore(photoDiv, gallery.firstChild); 129 + }} 121 130 }} 122 131 123 132 function removePhoto(filename) {{ ··· 136 145 }} 137 146 }} 138 147 139 - function deletePhoto(filename) {{ 148 + function deletePhoto(ditheredFilename, originalFilename) {{ 140 149 if (confirm('Are you sure you want to delete this photo?')) {{ 141 - fetch('/delete/' + filename, {{ 150 + fetch('/delete/' + ditheredFilename, {{ 142 151 method: 'POST' 143 152 }}).then(response => {{ 144 153 if(response.ok) {{ 145 - removePhoto(filename); 154 + return fetch('/delete/' + originalFilename, {{ method: 'POST' }}); 155 + }} 156 + }}).then(response => {{ 157 + if(response.ok) {{ 158 + removePhoto(ditheredFilename); 146 159 }} 147 160 }}); 148 161 }} ··· 176 189 try: 177 190 files = sorted(os.listdir(Config.PHOTO_DIR), reverse=True) 178 191 for filename in files: 179 - if filename.lower().endswith(('.jpg', '.jpeg', '.png')): 180 - timestamp = filename.replace('photo_', '').replace('.jpg', '') 192 + if filename.lower().endswith(('.jpg', '.jpeg', '.png')) and filename.startswith('dithered_'): 193 + originalFilename = filename.replace('dithered_', 'photo_') 194 + timestamp = filename.replace('dithered_', '').replace('.jpg', '') 181 195 photo_items += f""" 182 196 <div class="photo" id="photo-{filename}"> 183 - <img src="/{filename}" alt="{timestamp}"> 197 + <img class="dithered-img" src="/{filename}" alt="{timestamp}"> 198 + <img class="colored-img" src="/{originalFilename}" alt="{timestamp}"> 184 199 <div class="actions"> 185 - <a href="/{filename}" download>Download</a> 186 - <a href="#" onclick="deletePhoto('{filename}'); return false;">Delete</a> 200 + <a href="/{originalFilename}" download>Download Color</a> 201 + <a href="/{filename}" download>Download Dithered</a> 202 + <a href="#" onclick="deletePhoto('{filename}', '{originalFilename}'); return false;">Delete</a> 187 203 </div> 188 204 </div> 189 205 """ ··· 264 280 265 281 timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") 266 282 filename = f"photo_{timestamp}.jpg" 283 + dithered_filename = f"dithered_{timestamp}.jpg" 267 284 filepath = os.path.join(Config.PHOTO_DIR, filename) 285 + dithered_filepath = os.path.join(Config.PHOTO_DIR, dithered_filename) 268 286 logger.info(f"Taking photo: {filepath}") 269 287 270 288 picam2.capture_file(filepath) 271 289 logger.info("Photo taken successfully") 272 290 273 - # Rotate the image using PIL 274 - with Image.open(filepath) as img: 275 - rotated_img = img.rotate(Config.ROTATION, expand=True) 276 - rotated_img.save(filepath) 277 - logger.info("Photo rotated successfully") 291 + # Rotate the image using ImageMagick 292 + os.system(f"magick {filepath} -rotate {Config.ROTATION} {filepath}") 293 + logger.info("Photo rotated successfully") 294 + 295 + # Create dithered version using ImageMagick 296 + os.system(f"magick {filepath} -dither FloydSteinberg -define dither:diffusion-amount=100% -remap eink-4gray.png {dithered_filepath}") 297 + logger.info("Dithered version created successfully") 278 298 279 - # Notify websocket clients about new photo 299 + # Notify websocket clients about both photos 280 300 asyncio.run(notify_clients('new_photo', { 281 - 'filename': filename, 301 + 'filename': dithered_filename, 282 302 'timestamp': timestamp 283 303 })) 304 + 284 305 except IOError as e: 285 306 logger.error(f"IO Error while taking photo: {str(e)}") 286 307 except Exception as e:
src/eink-2color.png

This is a binary file and will not be displayed.

src/eink-4gray.png

This is a binary file and will not be displayed.