Skip to content

Repository files navigation

uscope

USB microscope capture for Linux — fast live view, a non-destructive image pipeline, and multi-frame stacking for noise reduction.

Cheap USB microscopes work fine under Linux as UVC devices, but the available software treats them as webcams: raw sliders, no stacking, no measurement. uscope is built for the way these cameras actually behave — heavy JPEG compression, few or no hardware controls, and quirks that have to be measured rather than assumed.

uscope main window

Install

Requires Python 3.9+, GTK 3, and libv4l. OpenCV is optional but strongly recommended — it accelerates the whole pipeline and enables 16-bit output.

Debian / Ubuntu / Mint

sudo apt install python3-gi python3-gi-cairo gir1.2-gtk-3.0 libv4l-0 \
                 python3-numpy python3-scipy python3-opencv python3-pyudev v4l-utils
git clone https://github.com/DrKropp/uscope-linux.git
cd uscope-linux
python3 uscope.py

Fedora

sudo dnf install python3-gobject gtk3 libv4l python3-numpy python3-scipy \
                 python3-opencv python3-pyudev v4l-utils

Arch

sudo pacman -S python-gobject gtk3 v4l-utils python-numpy python-scipy \
               python-opencv python-pyudev

Optionally install it properly, which adds a uscope command:

pip install --user .
cp uscope.desktop ~/.local/share/applications/

No root access is needed to run it: /dev/video* normally carries a logind ACL for the logged-in user. If you get a permission error, add yourself to the video group and log back in.

uscope            # or: python3 uscope.py
uscope --list     # list detected cameras
uscope -d /dev/video2

What it does

Split resolutions. Live view and capture are independent. The preview runs at a low resolution so it stays responsive (~20 fps at 1280x720); stills and stacks are grabbed at whatever you set Capture to, up to 4K. Switching modes and back is automatic and takes about a second.

Frame stacking. Capture N frames (4–128), align them, and combine with a sigma-clipped mean (also plain mean or median). Sigma clipping additionally rejects hot pixels, drifting dust and vibration blips. Results are kept in float32 and saved at 16 bits.

Live view modes.

  • Raw — every frame, unmodified.
  • Sharpest of N — shows only the sharpest frame in a sliding window. This cancels the camera's 4-frame focus pulsing (see below); measured on this hardware it made the view 37x steadier and, because it keeps the good frames, 1.17x sharper on average.
  • Rolling average of N — noise falls as sqrt(N). The window is bounded: an unbounded running average converges after a few hundred frames, stops responding to the subject, and effectively freezes.

Window size is adjustable (2–64) and the buffer can be reset after moving the stage.

Capture review, in its own window. A finished stack opens in a separate window with its own zoom, 1:1, loupe and histogram, so the live view keeps running behind it — you can carry on framing or refocusing without discarding first. "Compare single frame" swaps to one raw frame of the same burst so the improvement is directly visible. Image sliders in the main window retune the captured result live, so a stack can be tuned against the real 4K data rather than a preview. Save, Discard or Esc closes it.

Lucky-imaging selection. "Use sharpest" discards the softest frames before combining (all / 75% / 50% / 25%), trading a little noise reduction for real detail.

Live pipeline, applied to both preview and saved output, never baked into the source: white balance, black/white levels, gamma, chroma denoise, CLAHE local contrast, unsharp mask, saturation. Slider ranges are deliberately narrow — the useful adjustment for all of these sits close to neutral — and each one marks its default and snaps to it, so neutral is always reachable.

Built for modest hardware. The display path is 8-bit integer end to end: white balance, levels and gamma collapse into one 256-entry lookup table; chroma denoise, CLAHE and saturation share a single YCrCb round trip; and frames are only ever processed at the size the window can actually show. Exports still go through the full float path, so a stack's extra bit depth survives into the 16-bit file. Measured on a Ryzen 5 4500U, per displayed frame with every effect enabled:

stage before after
levels + gamma (720p) 41.9 ms 1.2 ms
whole pipeline (720p) 96.4 ms 11.9 ms
focus peaking (720p) 63.1 ms 9.6 ms
RGB frame → cairo surface 14.3 ms 0.2 ms
4K frame → screen, all effects ~1.5 s 44 ms

Focus aids. Focus peaking tints the sharpest edges, a 4x loupe follows the cursor, and a focus score (variance of Laplacian) is shown live — all useful because focus on these microscopes is a mechanical knob, not a camera control.

Export formats. The save dialog offers seven, smallest first, with a live size estimate and a quality slider for the lossy ones. Your choice is remembered. Measured on a 4K stack from this microscope:

format size notes
JPEG q95 1.6 MB 8-bit lossy; 4:4:4 chroma where OpenCV supports it
WebP q95 1.1 MB 8-bit lossy, slower to write
PNG 8-bit 9.1 MB lossless
TIFF 8-bit 10.8 MB lossless, ~3x faster to write than PNG
PNG 16-bit 39.5 MB full stack depth, slowest
TIFF 16-bit 40.9 MB full stack depth, DEFLATE
TIFF 16-bit uncompressed 49.8 MB widest compatibility

LZW is not offered: on noisy 16-bit data it produced a file larger than no compression at all. The dialog says plainly when an 8-bit format would discard the extra depth a stack recovered — that trade is usually worth it for sharing, and usually not for archiving.

Measurement. Calibrate against a stage micrometer, then measure lines, areas and circles, with an optional scale bar. Calibration is stored per camera and per resolution, since µm/pixel changes with sensor mode.

Adapting to the computer it runs on

Nothing is tuned for a particular machine. On first run uscope probes the host and benchmarks the real pipeline, then picks precision, preview resolution, thread count and stack memory from the measurement. Preferences shows what was found and lets you override any of it; "Auto-tune" re-measures.

Processing precision is selectable: 8-bit integer (fastest), 16-bit integer (avoids rounding between stages) or 32-bit float (highest quality). Exports always use the full float path regardless, so a stack's extra depth reaches the file either way.

GPU (OpenCL) use is measured, never assumed, and verified in a separate process. That is not paranoia: on the development machine an OpenCL driver passed a GaussianBlur probe and then hung indefinitely inside cvtColor on a GPU buffer — a failure no try/except can catch and which would otherwise wedge the whole app. Running the check out of process means the worst case is a subprocess that gets killed. The GPU is enabled only if it survives the real pipeline and beats the CPU by a clear margin; on integrated graphics competing with several CPU cores it frequently does not. The verdict is cached and re-checked only when the GPU stack changes.

Bit depth and monochrome

Capture keeps whatever the sensor gives. Frames are (H, W, C) with C of 1 (mono) or 3 (colour), in uint8 or uint16, and that depth is carried through stacking and into the file — it is not reduced to 8-bit on the way in.

Decoded natively: GREY (8-bit mono), Y10 / Y12 / Y14 / Y16 (mono), and Bayer mosaics at 8/10/12/16 bits for all four patterns, demosaiced at full depth. Sub-16-bit data is left-shifted so uint16 always means full scale, which keeps every later stage from having to know the sensor's real depth; the original depth is reported separately. Mono stays single-channel throughout — a third of the memory and work of tripling it — and is expanded to RGB only for display, or written as a genuine greyscale file.

Verified on a synthetic 12-bit mono source through the whole chain: a 16-frame stack went from 2,542 to 38,942 distinct 16-bit levels, and mean error against ground truth improved 3.9x — essentially the sqrt(16) = 4 the theory predicts.

Native depth is on by default and can be turned off in Preferences (it costs memory and time). It has no effect on cameras that only produce 8 bits, which is most USB microscopes — including the one this was developed against.

Caveats. Bayer pattern naming differs between V4L2 and OpenCV by one row/column; the conventional mapping is used, so if a Bayer camera's colours come out wrong, selecting a different Bayer format for the same sensor is the fix. None of the high-depth or Bayer paths have been tested against real hardware — only synthetic frames — because no such camera was available.

Camera backends

Capture sits behind a small interface (uscope/sources/base.py): a backend supplies modes, controls and uint8 RGB frames, and everything downstream — pipeline, stacking, measurement, export, UI — is already camera-agnostic. V4L2 is the built-in backend and the reference implementation.

Cameras that need a vendor SDK (ToupTek/AmScope, ZWO ASI, QHY, Basler, IDS, FLIR) are detected and named even though no backend ships for them, so you get "ToupTek camera at 0547:2205 — vendor SDK from touptek.com" rather than an empty device list. Adding such a backend means implementing Backend and Source and registering it; nothing else changes.

Notes on the Generalplus 1b3f:2002 microscope

Findings from probing the hardware, which shaped several defaults:

  • It exposes only Brightness, Contrast and Hue. No exposure, gain, white balance or focus control exists, so white balance and tone are handled in software.
  • MJPG is its only native format. YU12/YV12/RGB3/BGR3 come from libv4lconvert decoding that stream in software; the app captures through libv4l2 and never decodes raw MJPG itself.
  • Its JPEG is heavily quantised (~0.07 bytes/px, chroma quantisation table pinned near its maximum, 4:2:0 subsampling). That is the source of the colour banding, and it is baked in by the camera — no decoder recovers it. The fixes that do work are stacking (sensor noise dithers the quantiser, so the error averages down) and the chroma-denoise slider for live view.
  • 1920x1080 does not work with planar formats. 1080 is not a whole number of 16-pixel JPEG MCUs, so YU12/YV12 fail there. Those combinations are filtered out of the resolution list; RGB3/BGR3 do work at 1080.
  • Frame rate is not settable. S_PARM is silently ignored and always reports 30.
  • It pulses sharpness on a fixed 4-frame cycle. Measured across the cycle: mean luma varies 0.3%, the quantisation tables are byte-identical, and no frames are dropped — but the focus score swings ~2.1x and JPEG size swings 44%. So it is the camera's own firmware, not USB bandwidth and not host CPU (the period stays exactly 4 frames whether 2, 3 or 4 buffers are queued). "Sharpest of N" live view and the "Use sharpest" capture option both exploit it rather than fight it.
  • Per-pixel quality is best at 1280x720 (~0.087 B/px) and falls at higher resolutions (~0.050 B/px at 4K) because the camera holds a roughly fixed bit budget. 4K still resolves more detail; it is just more compressed per pixel.

If a mode does fail at runtime, the app rolls back to the last working one, keeps streaming, and remembers not to offer that combination again.

Files

uscope.py            entry point
uscope/v4l2.py       ctypes V4L2: discovery, controls, formats
uscope/capture.py    libv4l2 mmap streaming and pixel conversion
uscope/camera.py     capture thread, preview/capture mode switching, hotplug
uscope/pipeline.py   live image pipeline, focus peaking
uscope/stack.py      alignment, sigma-clipped mean, live stacker, fusion
uscope/measure.py    calibration, scale bars, measurements
uscope/export.py     16-bit TIFF/PNG output, overlay burn-in
uscope/config.py     settings, per-device profiles, capture paths
uscope/ui/           GTK3 interface

Config lives in ~/.config/uscope/, captures in ~/Pictures/uscope/<date>/, each with a sidecar JSON recording the device, mode, control values and pipeline settings.

Status and scope

Developed and tested against two USB cameras: a Generalplus 1b3f:2002 microscope and a Microdia PC-LM1E webcam. It should work with any UVC camera — device discovery, formats and controls are all probed at runtime, and a mode that fails is rolled back and remembered rather than taking the app down.

Not supported: cameras that need a vendor SDK (ToupTek/AmScope, ZWO, Basler, IDS, FLIR — these are detected and named, but not driven), multi-planar/CSI devices, and libcamera-only cameras such as Raspberry Pi CSI modules.

Bug reports from other hardware are genuinely useful — see CONTRIBUTING.md, which also documents how to add a camera backend. It is a self-contained job: everything downstream of a frame is already camera-agnostic.

Licence

GPL-3.0-or-later. See LICENSE.

About

USB microscope capture for Linux: live enhancement, frame stacking, and measurement for UVC cameras

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages