
For testing this out, I went to a remote area in the mountains near my hometown and used this map to navigate a road I had never been to before. I followed the map and was able to find my way without any issues.
This article covers the complete build process of this project, from the construction process of PCBs to code and device assembly, so let’s get started with the build.
HARDWARE-UNIHIKER M1



The star of our project is the UNIHIKER M10, which is a single-board computer that is quite unique. It comes with an onboard 2.8-inch touch display and has Wi-Fi and Bluetooth built in.
The thing that makes this device truly unique is the processor and co-processor. It is powered by an RK3308 ARM 64-bit processor, which has 4 cores and is clocked at 1.2GHz. It has 512MB DDR3 RAM and comes with an onboard 16GB eMMC chip.
For controlling GPIOs, it has a co-processor, the GD32VF103C8T6. The name might be long, but it’s a RISC-V processor clocked at 108MHz, with 64KB flash and 32KB SRAM.
The CPU cannot directly control GPIOs, so a co-processor has been added, which we can control using Python. The system controls the co-processor using the PinPong library.
The board is made by DFRobot, and you can check out more details about this board through their well-documented wiki page.
https://www.unihiker.com/products/m10
MAP UI BUILD

The UNIHIKER M10 runs full Debian Linux. Everything on it, including the screen, GPIO pins, and sensors, is controlled via Python, so there’s no Arduino IDE support and, sadly, no C.
For the map, I took inspiration from the Fallout: New Vegas Pip-Boy 3000 interface, which features an amber tint. Normal maps are boring, and as a Fallout fan, it was my duty to create a map inspired by the Pip-Boy.
Two libraries are used. The first is the UNIHIKER driver, which controls the built-in 240×320 display. Then there’s the PinPong library, which is used to read and write GPIO pins.
Displaying Frames
We first create the image widget once and just update its file on every frame.
gui = GUI()# Create oncedisplay_img = gui.draw_image(x=0, y=0, image="/tmp/frame.png")# Every frame: save new image, then update the same widgetframe.save("/tmp/frame.png")display_img.config(image="/tmp/frame.png")
Rendering with Pillow
All graphics are composed in memory using Pillow — no framebuffer, no pygame. Each frame goes through:
- Crop a 320×240 viewport from the 2000×2000 map
- Apply an amber tint
- Draw CRT grid + POI icons
- Composite scanline overlay
- Animate crosshair
- Draw HUD bars
- Rotate to landscape
- Save and push
```pythonfrom PIL import Image, ImageDrawviewport = self._map.crop((x0, y0, x0 + VIEWPORT_W, y0 + VIEWPORT_H))frame = viewport.convert("RGBA")draw = ImageDraw.Draw(frame)```
Amber Tint
The map loads as normal RGB. To get the Pip-Boy phosphor look, the Red channel is used as the brightness source for all three channels:
r, g, b = img.split()img = Image.merge("RGB", ( r, r.point(lambda v: int(v * 0.71)), # amber green r.point(lambda v: int(v * 0.26)), # amber blue))Ratios `(1.0, 0.71, 0.26)` match the target amber `(255, 182, 66)`.
Physical Buttons
Buttons are wired to GPIO pins with internal pull-up resistors. When pressed, they pull the pin LOW, so a reading of `0` means "pressed".
from pinpong.board import Board, PinBoard("unihiker").begin()btn_up = Pin(Pin.P3, Pin.IN, Pin.PULLUP)if btn_up.read_digital() == 0: move_y = -PAN_SPEEDif not hasattr(Pin, "PULLUP") and hasattr(Pin, "PULL_UP"): Pin.PULLUP = Pin.PULL_UP
Landscape on a Portrait Screen
The physical display is 240×320 (portrait). I draw into a 320×240 canvas (landscape) and rotate every frame before saving:
frame = frame.rotate(-90, expand=True)# result: 240×320 — fills the screen when held sideways
The Render Loop
Standard fixed-timestep loop — sleep for whatever time is left in the frame budget.
target_fps = 15frame_time = 1.0 / target_fpswhile True: t0 = time.time() # ... read buttons, render, push frame ......Read more »
Arnov Sharma












