dataray
Native, OS-agnostic Python interface and ISO 11146 beam analysis for DataRay beam profiling cameras.
DataRay ships a Windows-only application and a 32-bit ActiveX control. This package talks to the camera's USB endpoints directly through libusb, so you need neither DataRay's software nor Windows: it runs on Linux (including aarch64 boards such as a Jetson), macOS, and Windows, on 64-bit Python, with no kernel driver and no COM.
The analysis half is independent of the hardware half. dataray.analyze()
takes a plain 2-D numpy array and returns a full ISO 11146 beam profile —
second-moment (D4sigma) widths, principal axes and orientation, clip-level
widths, Gaussian fits — plus multi-frame statistics and an M-squared fit from a
through-focus scan. That works today on frames from any source.
Dependencies are deliberately thin: numpy is the only hard requirement. pyusb, requests and scipy are opt-in extras.
Hardware supported
| Camera | WinCamD-LCM, USB 3.0, 1632:3000 |
| Sensor | 1" global-shutter CMOS, 2048 x 2048, 4.2 MPixel |
| Pixel pitch | 5.5 um (11.264 x 11.264 mm active area) |
| Bit depth | 12-bit (0..4095 counts) |
| Exposure | 85 us .. 2 s on USB 3.0 |
| Firmware | reports LCM:1.1 (image LCM_1p1.img) |
DataRay's own DRIUSB3.inf binds the WaveCamD (1632:6001) to the same
USB 3.0 driver, so it should enumerate and speak the same protocol. It is not
in this package's sensor table, so it is described with a generic
2048 x 2048 / 5.5 um geometry that you should check against your datasheet; it
has not been tested here.
Install
The distribution is
dataray-bridgebut the import package isdataray—pip install dataray-bridge, thenimport dataray. Thedatarayname on PyPI belongs to an unrelated 2020 package.
pip install dataray # analysis only, numpy alone
pip install 'dataray-bridge[usb]' # + native USB access (pyusb / libusb)
pip install 'dataray-bridge[fit] # + scipy, for least-squares Gaussian fits
pip install 'dataray-bridge[laserlink] # + requests, for the LaserLink HTTP backend
pip install 'dataray-bridge[all] # usb + laserlink + fit
Linux: install the udev rule
Without this, libusb cannot open the camera and you get a
PermissionDeniedError. Write /etc/udev/rules.d/99-dataray.rules with
exactly the content of packaging/99-dataray.rules:
# DataRay beam profiling cameras — grant userspace (libusb) access.
# WinCamD-LCM / LCM series
SUBSYSTEM=="usb", ATTR{idVendor}=="1632", MODE="0660", GROUP="plugdev", TAG+="uaccess"
Then reload and replug:
sudo cp packaging/99-dataray.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger
Your user must be in the plugdev group for the GROUP="plugdev" line to
help (sudo usermod -aG plugdev "$USER", then log out and back in). The
TAG+="uaccess" also grants access to the user on the local seat under
systemd-logind. Unplug and replug the camera after reloading the rules.
Quickstart
Check what is attached:
dataray list
dataray --backend usb info
dataray --backend sim profile
dataray --backend sim capture -n 10 -o frames.npy
dataray --backend sim monitor --interval 0.5 --count 20
Measure a beam:
import dataray
with dataray.open_camera("sim") as cam: # or "usb", "laserlink", "ocx"
cam.exposure_us = 4000 # also cam.exposure_ms
cam.auto_exposure(target_fraction=0.65) # settle the peak at ~65% FS
profile = cam.capture_profile() # capture + analyze in one call
print(profile.centroid_x, profile.centroid_y) # microns
print(profile.d4sigma_x, profile.d4sigma_y) # microns (ISO 11146)
print(profile.d4sigma_major, profile.d4sigma_minor) # principal axes
print(profile.orientation) # degrees
print(profile.ellipticity, profile.is_circular)
print(profile.peak_value, profile.snr, profile.saturated_fraction)
print(profile.width_at(0.5, "x")) # clip-level (FWHM-equivalent)
Frame averaging and multi-frame statistics:
with dataray.open_camera("sim") as cam:
profile = cam.capture_profile(average=8) # sqrt(8) less read noise
stats = cam.measure(count=50) # ProfileStatistics
print(stats.d4sigma_x.mean, stats.d4sigma_x.pct_rms) # um, % RMS
print(stats.centroid_x.std, stats.orientation.mean)
from dataray.analysis.statistics import centroid_stability
print(centroid_stability(cam.capture_profiles(50))) # jitter vs drift
Background subtraction (block the beam first):
with dataray.open_camera("sim") as cam:
cam.acquire_background(frames=16) # averaged dark reference
# ... unblock the beam ...
profile = cam.capture_profile() # every frame is now dark-corrected
cam.clear_background()
Analysis with no camera at all:
import numpy as np
import dataray
frame = np.load("frames_000.npy") # anything 2-D works
profile = dataray.analyze(frame, pixel_size_um=5.5)
print(profile)
M-squared from a through-focus scan (ISO 11146):
from dataray.analysis.statistics import fit_m_squared
z_mm = [-60, -50, -40, -30, -20, -10, 0, 10, 20, 30, 40, 50, 60]
d4sigma_um = [...] # one D4sigma per station
result = fit_m_squared(z_mm, d4sigma_um, wavelength_nm=1064.0)
print(result.m_squared, result.waist_diameter, result.waist_position)
print(result.rayleigh_length, result.divergence) # mm, mrad (full angle)
print(result.zr_fit_quality) # R^2 of the d^2 parabola
fit_m_squared_from_profiles(z_mm, profiles, wavelength_nm, axis="major") does
the same directly from measured profiles, per principal axis.
Runnable versions of all of this:
examples/quickstart.py (defaults to sim, so it
runs anywhere) and examples/m2_scan.py.
Backends
Select with open_camera("<name>") or dataray --backend <name>.
| backend | needs | OS | notes |
|---|---|---|---|
usb |
pyusb + libusb | any | Native. Talks to the camera's bulk/control endpoints directly. No DataRay software. Frame streaming not working yet — see below. |
sim |
nothing | any | Synthetic WinCamD-LCM: gaussian / line / donut / multi beams with shot noise, read noise, exposure response and honest saturation. Fully functional. |
laserlink |
a Windows host running DataRay LaserLink (+ optional requests) |
any client | HTTP+JSON to that server; set DATARAY_LASERLINK_HOST / DATARAY_LASERLINK_PORT. Untested here — we have no Windows host. |
ocx |
Windows + DataRay's software installed + 32-bit Python | Windows | Drives DataRay's ActiveX controls, so results match their application exactly. Their OCX is a 32-bit in-process COM server and needs a GUI message loop; see the module docstring for the 32-bit workarounds. |
list_devices() probes usb, laserlink, then ocx; the simulator is only
used when you ask for it by name, so it never masks real hardware.
Status
The native usb backend captures live frames from a WinCamD-LCM. Verified
against physical hardware on an aarch64 Jetson Orin with no DataRay software
present:
- Enumeration and identification (
1632:3000, bus/address, string descriptors). - Firmware identity over vendor control read
0xB0— returnsLCM:1.1. - FPGA handshake (
0x43502B01) and the 128-byte FPGA status block (magic0x87654321, FPGA version 9). - Sensor configuration. The full 128-register file is written, then read back
and compared byte for byte, with the sensor's chip ID (
reg[0x7D] == 0x43) confirming the register bus really reaches the sensor. - Full-frame 2048x2048 image capture, 16-bit, as 8 sequential bulk transfers.
- Integration-time control with
auto_integration(), plus gain, ROI, binning and trigger configuration.
Measured on a laser sheet: FWHM across the line 303 µm, repeatable to 0.45% rms over six frames.
Caveats worth knowing
- Exposure works through the integration time, not the frame divider.
0x0Dsets the frame period; signal level comes from the sensor's signed 14-bit integration time. It is uncalibrated, so it is exposed as raw counts rather than dressed up as microseconds. Usecamera.backend.auto_integration()orset_integration_time().Camera.auto_exposure()drives the frame period and is much less effective. - Read frames sequentially.
read_frame()does this. If you write your own transfer loop, parallel readers give no guarantee which transfer receives which part of the frame, and the image scrambles in a way that looks like sensor noise. BeamProfile.truncatedflags a beam that overfills the sensor. ISO 11146's second moments require containment; a clipped beam gives a D4sigma that swings wildly frame to frame (observed: 420–6250 µm) while the FWHM stays stable to under 1%. When the flag is set, use the clip widths or Gaussian fit.- Frames report
bit_depth=16: the FPGA delivers 16-bit words and full scale is observed near0xFFF0, not the sensor's raw 12 bits.
Frame rate depends on the frame period (counter-intuitively)
Full-frame read_frame() timings, measured:
| frame period | taps | ms/frame | Hz |
|---|---|---|---|
| 10.909 ms (default) | 2 | ~270 | 3.7 |
| 24 ms | 4 | ~120 | 8.2 |
| 40 ms | 4 | no frame | — |
The tap mode dominates: the sensor switches from two 1024-column taps to four
512-column taps above an 11.25 ms period, and four taps read out about twice as
fast. So asking for a longer frame period makes capture roughly twice as
quick. cam.exposure_us = 24_000 is the sweet spot.
A 40 ms period yields no frames at all — avoid it. The device recovers on the next configuration.
Capturing a single shot when you don't know when it fires
If a pulse arrives at an uncertain time, do not free-run and hope to catch it. The camera reads a frame only every ~230 ms at full resolution while its integration window is ~11 ms, so a single pulse lands in a frame you actually read about 5% of the time (43% even at a 256x256 ROI). Measured, not estimated.
Use the hardware trigger instead: the pulse itself starts the exposure, so your timing ambiguity becomes irrelevant — 5 ms or 5 minutes, it makes no difference.
with open_camera(backend="usb") as cam:
cam.start()
cam.backend.auto_integration() # set the level while free-running
cam.use_hardware_trigger()
frame = cam.read_frame(timeout=60) # blocks until the pulse arrives
Or, as begin-collecting / fire / stop-collecting — one frame per pulse, so nothing is captured in between:
with open_camera(backend="usb") as cam:
cam.start()
cam.backend.auto_integration()
cam.use_hardware_trigger() # REQUIRED: without this it free-runs
# and you are back to the ~5% odds
cam.start_exposing(max_frames=8)
... # fire N shots whenever you like
cam.stop_exposing()
shots = cam.collect() # exactly the triggered frames
examples/single_shot.py implements both shapes.
Non-blocking collection
read_frame() blocks. When you would rather acquire in the background and pick
the results up later — for example catching every pulse of a triggered laser
while your own code runs — use the collection API:
cam.start_exposing(max_frames=32) # returns immediately, worker thread acquires
... # get on with something else
cam.stop_exposing()
frames = cam.collect() # take what accumulated
with cam.exposing(analyze=True, max_frames=500) as c: # or as a context manager
time.sleep(5.0)
profiles = c.collect()
Also cam.collect(count=N, timeout=S) to wait for N items, and
cam.is_exposing / collected / dropped / available.
This is not one long integration — the sensor's per-frame exposure is fixed by its own timing, so what accumulates is a series of frames. Sum or average them yourself if that is what you want.
Things worth knowing:
- The buffer is bounded and overruns are counted in
cam.droppedrather than eating memory. A full-resolution frame is 8.4 MB, so the defaultmax_frames=16already holds ~134 MB. analyze=Trueis slower than the camera. Full-frameanalyze()costs ~700 ms on an aarch64 Jetson against ~250 ms to transfer a frame, so the worker becomes analysis-bound and reads less often. Those frames are never read, so they are not counted as dropped — with a hardware trigger you would quietly miss pulses. For every frame, collect raw and analyze afterwards;fit_gaussian=Falsealso helps a lot.- The worker owns the device.
read_frame()is refused while exposing, because two threads reading one camera interleave bulk transfers and scramble frames.close()stops the worker first.
Hardware triggering
The camera has a trigger input (SMB TTL, plus an isolated optical input). Arming it makes every exposure start from an external edge, which is how you sync to a pulsed source such as a laser Q-switch:
with open_camera(backend="usb") as cam:
cam.start()
cam.backend.auto_integration() # set the level while free-running
cam.use_hardware_trigger(delay_us=0) # now each frame = one trigger edge
frame = cam.read_frame(timeout=1.0) # blocks until an edge arrives
cam.use_internal_trigger() # back to free-running
delay_us shifts the exposure window later relative to the edge, at 1 us
resolution up to 150 ms — a Q-switch sync usually leads the optical pulse, so a
few microseconds often helps.
From the CLI:
dataray -b usb profile --trigger hardware --trigger-delay-us 100 --timeout 1
examples/hardware_trigger.py captures a run of triggered frames and reports
per-frame latency and stability, or tells you plainly if no edges arrived.
Two things to know: read_frame() blocks waiting for a real edge and never
fires the exposure itself, so pass a timeout longer than the source's
repetition period. And set the integration time before arming — auto-exposure
needs frames, which in hardware mode only arrive when the source fires.
Status: the external mode is verified to arm correctly on hardware (mode 6 on the wire, software trigger suppressed and refused, clean blocking wait, mode round-trips cleanly). That an incoming edge actually produces a frame has not been confirmed here, because no trigger source was connected and firing during development.
Other backends
simworks fully, end to end, including every CLI subcommand.- Analysis works on any numpy array, from any source.
laserlinkis implemented against DataRay's documented REST surface and their own examples, but has never been run against a live server here; its image-decode path is written defensively for that reason.ocxis implemented but only usable on Windows with DataRay's software and a 32-bit interpreter, which is not the environment this was developed on.
Protocol notes
Everything below was recovered by black-box analysis of a physical camera plus
static analysis of DataRay's shipped binaries. In the source, facts are labelled
CONFIRMED (observed on the device or read out of a binary) or INFERRED, because
a wrong guess in a wire format yields silently wrong measurements rather than an
obvious failure. See src/dataray/_proto/.
Architecture. The camera is a Cypress FX3 running a 3-channel synchronous Slave-FIFO design — a transparent DMA bridge between the USB endpoints and an FPGA. The FPGA, not the FX3, interprets camera commands. That is why the control endpoint only exposes identity and status.
Endpoints. Interface 0 is vendor-specific with four bulk endpoints
(wMaxPacketSize 1024 at SuperSpeed, bMaxBurst 3):
- EP
0x02OUT — commands. An array of little-endian 32-bit words,opcode << 24 | payload(24-bit payload). Opcode0x00is a NOP/pad, so a short command may be zero-filled to a larger block. Every single-value command is emitted as two words: the command, then0x63carrying the identical payload, which latches it. Register writes are0x64 | (index << 8) | value. - EP
0x81IN — images. Raw 16-bit little-endian pixels,width * height * 2bytes, no header. - EP
0x82IN — status / register readback. The status block is 128 bytes and starts with magic0x87654321; a register readback block carries0x5678in the high half of its first dword, and each dword isindex << 8 | value. Opcode0x03selects which of the two the next read returns. - EP
0x01OUT is enumerated and wired to a DMA channel by the firmware but never used by DataRay's software.
CLEAR_FEATURE(ENDPOINT_HALT) is implemented in firmware as a full DMA channel
reset, so it is the supported way to resync a wedged pipe.
Vendor control reads. All device-to-host (bmRequestType 0xC0), fixed
length, constant in wValue/wIndex: 0xB0 identity (8 ASCII bytes),
0xE2/0xE3 status, 0xE4/0xE5 alive sentinels (both 0xAA), 0xE6
config word (4 bytes), 0xE7 config short (2 bytes). 0xE0 reads the FPGA
register space; with wValue = 0x0080 it returns the FPGA/PSoC identification
word. This set was established by sweeping all 256 request codes across
bmRequestType 0xC0/0xC1/0xC2 and 0xA0/0xA1/0xA2, and by sweeping wValue
and wIndex on every responder.
Do not do this
Never issue vendor request
0xE1withwValuein the range0xA0–0xAF. On this firmware that address range is the FX3 boot EEPROM. Writing it destroys the boot image and permanently bricks the camera — it will no longer enumerate and cannot be recovered over USB.More generally, do not send any vendor control OUT transfer while exploring. On Cypress FX3 firmware, vendor OUT codes commonly map to I2C/EEPROM writes. Bulk writes to EP
0x02land in an FPGA FIFO instead: a misunderstood register write there is recoverable with a power cycle and cannot rewrite firmware. Every probe script inscripts/is restricted to control IN transfers and bulk writes for exactly this reason.
Development
python -m venv .venv
.venv/bin/pip install -e '.[all,dev]'
.venv/bin/python -m pytest -q
The test suite runs entirely on synthetic beams with known ground truth and needs no hardware.
License
MIT — see LICENSE. Copyright (c) 2026 Matteo.
Disclaimer
This is an independent, unofficial project. It is not affiliated with, endorsed by, or supported by DataRay Inc. "DataRay", "WinCamD" and "LaserLink" are their names, used here only to say which hardware this talks to.
The protocol description was produced by black-box analysis of a device that was purchased outright, together with examination of publicly distributed driver and application files, for the sole purpose of interoperability — making the hardware usable from Python on operating systems the vendor does not support. No vendor source code is included or redistributed. Use it at your own risk: it can misconfigure your camera, and nothing here is calibrated or warranted for metrology you intend to rely on.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dataray_bridge-0.2.0.tar.gz.
File metadata
- Download URL: dataray_bridge-0.2.0.tar.gz
- Upload date:
- Size: 127.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.10.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7e73de1bd259e4698c3ed4a15a938b3269f83510568a2cb3ee127a0b093b26db
|
|
| MD5 |
5c04e6dab976b82923bb981887298431
|
|
| BLAKE2b-256 |
f4ca47f3a29bba7151cf379d1a45fc04c51b85fe4eca8728ef08d66655060372
|
File details
Details for the file dataray_bridge-0.2.0-py3-none-any.whl.
File metadata
- Download URL: dataray_bridge-0.2.0-py3-none-any.whl
- Upload date:
- Size: 93.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.10.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5215244fd22a8532f407abd2722af5b41d87964411f7b15003c255bbf0f47f3d
|
|
| MD5 |
42115a60e6ff41b0624d9ff8694ebee9
|
|
| BLAKE2b-256 |
ea9bffaba24f80d4dce555e39b45b83027bc3dd59723e0c5472ef95549e33c7b
|