Skip to main content

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-bridge but the import package is dataraypip install dataray-bridge, then import dataray. The dataray name 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 — returns LCM:1.1.
  • FPGA handshake (0x43502B01) and the 128-byte FPGA status block (magic 0x87654321, 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. 0x0D sets 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. Use camera.backend.auto_integration() or set_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.truncated flags 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 near 0xFFF0, 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.dropped rather than eating memory. A full-resolution frame is 8.4 MB, so the default max_frames=16 already holds ~134 MB.
  • analyze=True is slower than the camera. Full-frame analyze() 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=False also 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

  • sim works fully, end to end, including every CLI subcommand.
  • Analysis works on any numpy array, from any source.
  • laserlink is 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.
  • ocx is 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 0x02 OUT — commands. An array of little-endian 32-bit words, opcode << 24 | payload (24-bit payload). Opcode 0x00 is 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, then 0x63 carrying the identical payload, which latches it. Register writes are 0x64 | (index << 8) | value.
  • EP 0x81 IN — images. Raw 16-bit little-endian pixels, width * height * 2 bytes, no header.
  • EP 0x82 IN — status / register readback. The status block is 128 bytes and starts with magic 0x87654321; a register readback block carries 0x5678 in the high half of its first dword, and each dword is index << 8 | value. Opcode 0x03 selects which of the two the next read returns.
  • EP 0x01 OUT 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 0xE1 with wValue in the range 0xA00xAF. 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 0x02 land in an FPGA FIFO instead: a misunderstood register write there is recoverable with a power cycle and cannot rewrite firmware. Every probe script in scripts/ 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

dataray_bridge-0.3.0.tar.gz (146.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

dataray_bridge-0.3.0-py3-none-any.whl (97.5 kB view details)

Uploaded Python 3

File details

Details for the file dataray_bridge-0.3.0.tar.gz.

File metadata

  • Download URL: dataray_bridge-0.3.0.tar.gz
  • Upload date:
  • Size: 146.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.10.12

File hashes

Hashes for dataray_bridge-0.3.0.tar.gz
Algorithm Hash digest
SHA256 2f585aa7c2b82de4c2bcefa86b56d97f18afa5f3f4964418e2f1549f8798aabd
MD5 2b75e931891b8a25b4d2785b3663ebd9
BLAKE2b-256 096f25d8dc945f45134b44c7cf812a84468d05b3f233ae788873b735271b65c8

See more details on using hashes here.

File details

Details for the file dataray_bridge-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: dataray_bridge-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 97.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.10.12

File hashes

Hashes for dataray_bridge-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 85e7126bdb0196ab94b072a7d3d2c4b07687381b4edfc05d467c6f454295640e
MD5 71b59f9cd233239daa2300c053857bec
BLAKE2b-256 93a371d73f61b67af9ef78e446e061487195c9ac53e91001b82bfb5a45774375

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page