Metadata-Version: 2.4
Name: dataray-bridge
Version: 0.1.0
Summary: Native, OS-agnostic Python interface and ISO 11146 beam analysis for DataRay beam profiling cameras.
Author-email: Matteo <matteo@optica.industries>
Maintainer-email: Matteo <matteo@optica.industries>
License: MIT
Project-URL: Homepage, https://github.com/optica-industries/dataray-bridge
Project-URL: Source, https://github.com/optica-industries/dataray-bridge
Project-URL: Issues, https://github.com/optica-industries/dataray-bridge/issues
Project-URL: Documentation, https://github.com/optica-industries/dataray-bridge/blob/main/docs/protocol.md
Keywords: dataray,wincamd,beam-profiler,beam-profiling,iso-11146,m-squared,laser,optics,metrology
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Scientific/Engineering :: Physics
Classifier: Topic :: Scientific/Engineering :: Image Processing
Classifier: Topic :: System :: Hardware :: Hardware Drivers
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.21
Provides-Extra: usb
Requires-Dist: pyusb>=1.2; extra == "usb"
Provides-Extra: laserlink
Requires-Dist: requests>=2.25; extra == "laserlink"
Provides-Extra: fit
Requires-Dist: scipy>=1.7; extra == "fit"
Provides-Extra: all
Requires-Dist: pyusb>=1.2; extra == "all"
Requires-Dist: requests>=2.25; extra == "all"
Requires-Dist: scipy>=1.7; extra == "all"
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Dynamic: license-file

# 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
> **`dataray`** — `pip install dataray-bridge`, then `import dataray`. The
> `dataray` name on PyPI belongs to an unrelated 2020 package.


```bash
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`](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:

```bash
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:

```bash
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:

```python
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:

```python
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):

```python
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:

```python
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):

```python
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`](examples/quickstart.py) (defaults to `sim`, so it
runs anywhere) and [`examples/m2_scan.py`](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.

### 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
> `0xA0`–`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 `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

```bash
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](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.
