ICCP 2026 Summer School

Interferometric Imaging

Build a Michelson-style interferometer on a breadboard, align it to see white-light interference fringes, and use it as a time-domain OCT scanner to recover a depth map of a coin.

Where this is going: by the end of this guide you will have built a scanner that can map a surface down to micron scale — the depth map below, and the transient video of the coherence gate sweeping across a coin, are both produced by the instrument you're about to build.

The setup you will build — a Michelson interferometer on a breadboard. Light from the LED is split at the beamsplitter into two arms — the reference mirror on the motorized stage, and the object (a mirror at first, later your coin) on the XY stage — and recombines into the camera:

Top-down labelled photo of the interferometer: LED and telescoping tube, camera and lens, beamsplitter on rotation stage, object mirror on XY stage, reference mirror on motorized stage, LED driver, KDC101 controller
The assembled instrument, with every part you will mount and align labelled.

The results — what this instrument measures:

Teaser: micron-scale OCT depth map of a coin
A micron-scale depth map of a coin, recovered by your scanner.
videoVisualizing the temporal coherence allows for pseudo-transient imaging — raw frames left, interference contrast right.
Tip: steps throughout this guide include short video clips — watch them as you go; they show you what you should expect to see at each stage, both on the breadboard and on screen.
Part 1

Construction

1Start with the breadboard

Your breadboard should look something like this — the LED driver (red box) and the LED mount are already in place:

Bare breadboard with LED driver and LED mount

2Mount the beamsplitter post

Mount the main post with the beamsplitter (BS) using the cap screws at the designated location on the breadboard. Depending on your specific setup, the position of the beamsplitter may not match the one shown below. Ask one of us to verify if the positioning is correct.

Beamsplitter post placed on breadboard Beamsplitter post secured with cap screws

3Light source arm

  1. Screw on the 100 mm Thorlabs lens (AC254-100-A-ML).
    Thorlabs AC254-100-A-ML lens in its box Screwing the lens onto the cage cube
  2. Screw on the LED with the telescoping tube onto the threading of the lens.
    LED with telescoping tube being attached
  3. Connect the LED to the LED driver, and try powering it up with the power adapter.
    Careful: the LED can be very bright — try not to look directly into it. Turn the driver's power knob down before switching it on.
    Connecting the LED cable to the driver Powering up the LED driver

4Camera arm

  1. Screw the 105 mm (or 200 mm) Nikon lens on the threading at the right end of the BS cube.
    Nikon lens attached to the right of the BS cube
  2. You should have a C-mount camera that looks like this:
    Three C-mount cameras
  3. Screw the C-mount camera onto the F-C mount, and then attach it to the back of the lens.
    Camera with F-C mount adapter Screwing camera onto adapter Assembled camera and adapter Attaching the camera to the back of the lens Camera mounted on the lens
    Careful: careful not to leave bare sensor open too long while handling the camera (to prevent dust/damage).

5Reference arm

  1. Place the XY stage (with the motorized Z825B actuator inserted) on the breadboard (at the designated location indicated by screws) and secure it using washers and cap screws.
    Reference XY stage placement XY stage with Z825B actuator Stage secured on the breadboard
  2. Careful not to touch or damage the mirror — screw the round mirror mount onto the stage using the set screw at the bottom.
    Round mirror mount in hand Mirror mount post Mirror mount installed on the stage
  3. Connect the end of the Z825B motor to the KDC101 K-cube motor controller (red cube).
    Z825B motor cable routed to controller Plugging motor into the KDC101

6Object arm

  1. Similar to the reference arm, place and secure the XY translation stage for the object side at the designated location. Your stage may look slightly different from the XY stage in the image.
    Object-side XY translation stage Placing the object stage on the breadboard Object stage secured
  2. Drop in the post holding the rectangular mirror, careful not to touch or damage the mirror.
    Rectangular mirror post in hand Dropping the mirror post into the stage
  3. Use the lateral micro-adjusters to position the mirrors to be facing directly opposite the cage-cube opening.
    Adjusting the mirror position Mirror facing the cage cube Both arms with mirrors positioned Top view of the completed arms

7Fix the base rotation stage

Fix the orientation of the base rotation stage at the centre (ask for a 5/64 hex key) such that the LED and camera are roughly along the breadboard holes.

Tightening the base rotation stage LED and camera aligned along breadboard holes Hex key adjustment of the rotation stage
Part 2

Alignment

Everything in this part runs from the Python scripts in this repository, inside the conda environment from environment.yml.

1Hardware sanity check

Before touching any optics, make sure the laptop can actually talk to the stage and the camera. Plug in the KDC101's USB cable, switch it on with the red switch, connect the camera's USB cable, then run:

Windows: install the Thorlabs Kinesis software first — it provides the APT/FTDI driver the stage is detected through; without it the stage test will fail even though everything else is set up correctly. (See the Software installations section below.)
# one-time setup (see Software installations below for details)
git clone https://github.com/cmu-ci-lab/summer-school.git
cd summer-school

Then build the Python environment. Pick one of the three options below (see Software installations for the full details):

Option 1 — Recommended
Let the script decide (conda if you have it, else a .venv):

./setup.sh

Option 2 — Don't want conda?
Force a plain .venv, even if conda is installed:

FORCE_VENV=1 ./setup.sh

Option 3 — Manual conda
Skip the script and build the env yourself (needs conda):

conda env create -f environment.yml

Then activate the environment. Which command you use depends on which kind of environment setup.sh built — it prints the right one when it finishes (conda is preferred when you have it; otherwise it creates a .venv):

Got conda? — setup.sh made a conda env:

conda activate iccp-oct

No conda? — setup.sh made a .venv:

source .venv/bin/activate
# Windows: .venv\Scripts\activate
Wrong python? If a script fails with ModuleNotFoundError (pylablib, cv2, ...), you are probably running the wrong Python. The repo ships a ./python wrapper (already executable) that always resolves the project environment — just prefix any command with ./, e.g. ./python visualizer.py. Equivalently, roll your own symlink with the environment active: ln -sf "$CONDA_PREFIX/bin/python" python — see Troubleshooting.

After the environment is built, install the camera SDK — this is a separate step that environment.yml doesn't cover, and which one you need depends on the camera on your rig: the Vimba X SDK (vmbpy wheel) for Allied Vision, or the IDS Software Suite plus pyueye for IDS uEye — note that IDS cameras work on Windows only (the IDS Software Suite has no macOS support), so pair IDS rigs with a Windows laptop. See Software installations below for step-by-step instructions.

Caution (macOS): the Vimba X installer package may be blocked by macOS when you run it. If that happens, open System Settings → Privacy & Security, scroll down to the message about the blocked installer, and click Open Anyway to allow it to run.

With the environment active and the SDK in place, verify stage and camera connectivity:

python test_hardware.py

Or, to skip a vendor you don't have: python test_hardware.py --camera ids (IDS only — skips the Vimba X check) or --camera avt (Allied Vision only).

Caution: if you're using an Allied Vision camera, you will need to pip install the vmbpy package. test_hardware.py will automatically prompt you with instructions to do so.

The stage test connects, reads the position, moves +5 mm and back, and verifies the readback — so make sure the stage has 5 mm of clearance before you run it. The camera test connects, grabs a frame, buffers three more, and writes a small stack to test_captures/.

What passing looks like — each step prints a checkmark as it succeeds; a healthy run looks like this:

  TEST: Stage (Thorlabs KDC101)
✓ Imported ThorlabsStage
✓ Connected to stage
✓ Read initial position: 0.000 mm
✓ Moved to 5.000 mm
✓ Position verified: 5.000 mm (error: 0.001 mm)
✓ Moved back to initial position
✓✓✓ Stage test PASSED

  TEST: Camera (IDS)
✓ Connected to camera
✓ Captured frame: shape=(2076, 3088), dtype=uint16
✓ Captured 3 frames to buffer
✓ Saved stack: test_captures/test_stack.npy (36.6 MB)
✓✓✓ Camera test PASSED

  SUMMARY
  Stage:  ✓ PASS
  Camera: ✓ PASS

Things to check in that output beyond the checkmarks:

  • Position error — after the +5 mm move, the test reads the stage's own encoder back and compares it with the commanded target; the difference should be well under 0.1 mm. You'll essentially never see it fail — a ⚠ warning here usually just means the readback happened before the motion fully settled, or (rarely) the wrong stage model was auto-detected.
  • Frame shape and dtype should match your sensor — full resolution, and uint16 if the camera runs a 10/12-bit mode (IDS) rather than 8-bit.
  • The saved stack size should be plausible (a few frames × resolution × 1–2 bytes).
Stage not found? Check the USB cable and that the KDC101 is powered on. On macOS remember: no serial port is expected (the KDC101 is driven directly over libusb, so libusb must be installed). If a script crashed earlier, the hardware may still be held by its Python kernel — restart the kernel and retry. A camera failure is usually a missing vendor SDK; the script prints exactly which package to install (e.g. the vmbpy wheel from Vimba X).

2Getting light into the camera

  1. Ensure the lens aperture is fully open.
    Nikon lens aperture ring set fully open
  2. Launch the visualizer — everything in the rest of this part happens inside it. It auto-detects the camera (AVT or IDS), connects to the stage, and homes it on startup:
    # live camera preview + coherence panel
    # --step: coarse stage step in mm; --window-mm: positional window for the DC mean
    python visualizer.py --step 0.01 --window-mm 0.2
    Stage control: the reference stage is driven from the keyboard — w/s jog by the coarse step, e/d by the fine step (coarse ÷ 10), and [/] change the step size. To sweep, just hold w or e — the stage moves continuously; there is no need for slow individual presses. The status bar along the bottom shows exposure, frame rate, the current stage position, and the step sizes.
    Don't sweep backwards: the backward keys s/d produce a mechanical kick-back when the stage reverses direction (backlash). Use them only to reposition; always take your measurement sweeps forward with w/e.
    Ignore the coherence panel for now — the plot and stat tiles on the right of the window aren't needed until Section 3 (OCT). In this section you only use the live image and the stage keys.
  3. Adjust your camera to be roughly upright by rotating it about the rotation mount with graduations.
    videoRotating the camera about the graduated rotation mount until the image is upright.
  4. Switch on the LED (careful — it can be very bright; avoid looking directly into it), and try to coarsely align the orientation of the mirrors to get maximum light into the camera by:
    1. coarsely adjusting the mirror post mounts,
    2. using the mirror adjuster knobs.
    videoThe mirror adjuster knobs on the kinematic mount — these tilt the mirror to steer its image of the LED.

    You should see two ‘blobs’ — (likely blurred) images of the LED — that move as you adjust the mirrors.

    Visualizer showing the two blurred blobs of LED light
    The visualizer with the two (blurred) images of the LED in the live view — coherence panel still empty on the right.
    Note: You may need to lower the LED power and reduce your exposure. Adjust exposure live with the +/- keys in the visualizer, or relaunch with --exposure <µs>.

3Getting to know the visualizer

Everything in the rest of this guide happens inside the visualizer, so it is worth taking some time now to play around with it — jog the stage, change the exposure, select and clear patches, zoom around the plot. Nothing you do here can break anything, and being fluent with the controls makes the alignment steps much faster.

Visualizer

The visualizer with numbered red boxes: 1 live camera view, 2 stat tiles, 3 coherence plot, 4 status bar, 5 selected patch and its magnified inset
The parts of the visualizer, numbered to match the list below.
  • ① Live camera view. The raw camera feed. Click twice on it to select a patch (first click anchors a corner, second finalizes).
  • ② Stat tiles. Coherence FWHM and peak (filled in once a full envelope has been swept through) and the live amplitude of the selected patch.
  • ③ Coherence plot. Builds up patch amplitude vs stage position as the stage moves. Scroll the mouse wheel over it to zoom, drag to pan.
  • ④ Status bar. Exposure (µs), gamma, frame rate, and binning on the left; the current stage position and the coarse / fine step sizes in the middle; key hints on the right.
  • ⑤ Selected patch. The blue box marks the patch whose interference amplitude the panel is tracking. The panel also shows that patch magnified, between the stat tiles and the plot, so you can see the fringes and speckle at sensor resolution instead of squinting at the small box on the live view. Press i if the fringes are too faint to make out.

Keyboard controls:

  • w / s — jog the stage forward/back by the coarse step; hold w to sweep
  • e / d — jog forward/back by the fine step (coarse ÷ 10); hold e for a slow sweep
  • [ / ] — halve / double the coarse step size
  • i — toggle auto-contrast on the magnified patch shown in the panel. The stretch makes faint fringes visible, but it exaggerates contrast and hides how close you are to saturation — the inset is labelled i: auto-contrast whenever it is on, so check that before you judge exposure
  • m — move to a position you type: press m, type the value in mm and press Enter (a leading + or - moves relative to where you are; Esc cancels). Handy for jumping straight back to a centre you found earlier
  • + / - — brighten / dim (exposure)
  • f — fine scan: once you have coarsely found the peak, steps ±0.5 mm around the current position in 10 µm steps (the plot resets as the sweep begins), saves a contrast-vs-position plot (PNG + CSV) and moves the stage to the peak
  • g — refine scan: same as f but ±0.05 mm in 1 µm steps — run it after f has landed close, to polish the peak. Pressing f or g during a scan cancels it
  • a — autoscale the plot (undo zoom/pan)
  • c — clear the patch selection and the plot
  • r — reset the plot but keep the patch
  • q — quit
Disclaimer: the visualizer is subject to change — the exact layout, labels, and controls may differ a little from the screenshots and videos in this guide. That's ok: the pieces above (live view, stat tiles, plot, status bar) stay the same.

4Collimating the LED

  1. Set the lens to infinity focusing.
    videoTurn the focus ring until the ∞ symbol sits over the index mark in the distance window (highlighted by the green box).
  2. Adjust the telescoping tube until the images of the active element of the LED (this may look different for each LED) look as sharp as possible.
    Note: if you have the SM1NR1 telescoping tube, the LED should not rotate as you adjust it — only the tube extends. Some of the lens tubes (SM1U) do rotate as you turn them, so don't be alarmed if the LED spins with the adjustment on those.
    videoSM1NR1 — the tube extends without rotating the LED.
    videoSM1U — the LED rotates with the adjustment.
    videoCollimating: the two images of the LED's active element sharpen up as the telescoping tube is adjusted.
    Note: LEDs come in different shapes, so don't expect yours to look like the one in the video — it just has to come into focus.
    Tip: You may choose to block one of the arms using a piece of card to make alignment easier.

5Making the mirrors parallel

Use the adjusters on the mirror mounts to position both images of the LED roughly at the center of the field of view of the camera (better aligned, the better). You can use the inset preview by selecting a crop region to help you align them precisely.

videoWalking the two LED images on top of each other with the mirror adjuster knobs.
Part 3

OCT

Now that we've collimated our light source and made the mirrors nearly parallel, we will now align it to use it for a time-domain OCT scanner: match the pathlengths of the two arms, measure the coherence length (your depth resolution), then scan an object and reconstruct its depth map.

1Coarse pathlength matching

  1. Focus the lens onto the object-side mirror. Block the mirror on the motorized stage using a piece of card. Change the lens focusing such that the other mirror (straight opposite the camera) comes into focus (about 1:1).
    videoChanging the lens focusing — turn the focus ring on the Nikon lens.
    videoBringing the mirror surface into focus at about 1:1 — watch the dust and scratches on the glass sharpen up.

    You will need to increase the exposure time and/or LED power.

    Tip: Laterally move the mirror using the micro-adjusters until its edge comes into view, and make sure to focus on the edge — it gives you something sharp to judge the focus by. You may also use dust or scratches present on the mirror.
    videoLaterally translating the mirror with the stage's micro-adjuster to bring its edge into view.
    videoFocusing on the mirror's edge until it is sharp.
  2. Unblock the mirror on the motorized stage. Leaving the lens focus setting fixed, adjust the stage position using the keys until this mirror comes into focus as well. You can use the same trick of laterally moving the mirror to bring its edge into view. The edge may not be perfectly at te same depth as the surface of the mirror, so try and refine it to focus on dust/scratches on the mirror. You should now have both mirrors in focus.
  3. Now that both of the mirror arms are in focus, they are now coarsely matched to be at about the same distance from the beamsplitter. We now need to make this a lot more precisely matched (within the coherence length of the light source, typically <40 µm) Select a patch on the live view — two clicks: the first anchors a corner, the second finalizes it. The coherence panel now tracks that patch as you move the stage.
    videoSelecting a patch: one click to anchor a corner, a second to finalize.

    What the panel is computing

    The plotted value is the computeMeanDiff measure: within the patch, take each frame's absolute deviation from the local (running) mean intensity, and average it — mean |I − DC|. The DC mean is the plain image; what's left is the amplitude of the interference fringes. That amplitude is essentially zero everywhere except when the two arms' pathlengths match to within the coherence length — so as you step the stage, the plot sits at a noise floor and then jumps in a sharp peak at the matched position. (Step 4 uses this same measure per-pixel to build the depth map.)

    If you're lucky, you may be able to perform precise alignment of the reference mirror (on the motorized stage) to match with the object-side mirror (right opposite the camera) using just the visualizer and using the w/s keys to move the stage. Most likely, you will need to automate the precise alignment, as described in the next step.

2Fine pathlength matching

  1. With a patch selected the f key runs a local scan centered around the current position of the stage— it covers a range of ±0.5 mm around the current position in 10 µm steps, then moves the stage to the peak and saves a contrast-vs-position plot.
  2. Once you've found a peak you should be able to see fringes already. You can use the mirror-orientation adjusters to change the interference fringe pattern:
    videoThe fringe pattern changing as the mirror-orientation adjusters are turned — the spacing and curvature of the fringes follow the relative tilt of the two mirrors.
  3. Follow it with a scan with the g key to repeat the same kind of scan, but for a 10× finer range (±0.05 mm in 1 µm steps) and nail the centre to obtain peak fringe contrast.

3The temporal coherence length

Performing a fine scan using either the f or g keys will give you a fully resolved coherence envelope. Find a saved plot of the interference contrast curve as a function of motor position in live_view_captures/. The FWHM of this envelope is the temporal coherence length of the light source, which sets the depth resolution for OCT. You should also find it reported at the top panel of the visualizer window.

This temporal coherence length of the spectrally filtered light reaching the camera should be our depth resolution for OCT.

Visualizer after a fine sweep: resolved coherence envelope with FWHM 29.3 um
After the fine sweep the envelope is fully resolved and the FWHM tile reads out the coherence length — here ≈ 29 µm at a peak of 21.17 mm.

What should we expect?

The coherence length is set by the spectrum of the light: the wider the bandwidth, the faster the two arms decorrelate as their pathlengths separate. In terms of the frequency bandwidth Δf — or, more usefully here, the central wavelength λ and wavelength bandwidth Δλ —

L = cn Δf  ≈  λ2n Δλ

with n = 1 in air. For our LED behind the 10 nm bandpass filter (λ ≈ 660 nm, Δλ ≈ 10 nm) this gives L ≈ (660 nm)² / 10 nm ≈ 44 µm. For a Gaussian spectrum the exact FWHM-to-FWHM relation carries an extra factor of 2 ln 2/π ≈ 0.44, i.e. L ≈ 19 µm — so a measured envelope FWHM of a few tens of microns (here ≈ 28 µm) is right where it should be. Without the filter, the LED's full ~25 nm bandwidth would cut this roughly in half — and some LEDs are much broader still (the 565 nm one, for example).

4Scanning a coin

  1. Now that we've aligned and characterized our OCT system, we're ready to scan objects! When you're ready, ask for a coin sample mount to swap in place of the rectangular mirror, and mount a coin (quarter/euro/…) on it.
    Coin mounted in place of the rectangular mirror
  2. Since the coin scattters light, it doesn't reflect all of the incident light back to the camera, and will appear much darker. Insert the 0.6 OD ND filter (placed at a slight angle to avoid inter-reflections) in the light path to roughly match intensities from both arms. (You will need to readjust your exposure time.)
    ND filter inserted at an angle in the light path
  3. If we now scanned the reference arm through the entire range of the stage, we will be able to reconstruct a depth map of the coin based on when interference is detected at each region of the coin. However, since the step size will need to be on the order of the coherence length (tens of microns), this would take nearly forever to complete. Instead, we would like to align the reference mirror to a match the surface on a patch region on the coin using the same procedure that we followed for the mirror, and then do a short 1 mm OCT scan around that aligned position.
  4. To perform coarse alignment, leave the reference mirror and lens focusing fixed as it was before, and bring the coin into focus using its XY stage — turn the micro-adjusters until the coin's surface detail is sharp in the live view. Just like we did for the mirror, we now have them coarsely matched, using the f key, we can automatically find the motor position that matches with the patch on the coin:

    • Press f for a coarse scan: it sweeps ±0.5 mm around the current position in 10 µm steps, plotting the patch contrast as it goes, and then tries to find a peak and moves the stage to it. When it lands you should see the speckle quite prominently in the patch — that's the coherence gate sitting on the coin's surface.
    • Then, if you want to scan it better, press g for a fine scan: the same, but ±0.05 mm in 1 µm steps, to polish the peak position. (Pressing f or g again while a scan is running cancels it.)
    videoPressing f: the coarse scan sweeps ±0.5 mm around the current position and lands on the coin surface's contrast peak.
    videoPressing g: the fine scan re-sweeps ±0.05 mm in 1 µm steps and resolves the full envelope around the peak.
  5. Quit the visualizer, then perform a scan over a length of ~1 mm with oct_scan.py. Use the temporal coherence length determined in step 3 to choose the step size — a few steps per FWHM, e.g. ~28 µm coherence → 5–10 µm steps. The captured stack may take a while to save. For the example below we used a 0.9 mm range with 3 µm steps, centred on the peak found above. The frame count is computed for you (range/step + 1), and if you leave out --exposure the scan reuses the exposure from your last visualizer session (it is recorded in last_exposure.json whenever you adjust it):
    # 0.9 mm range x 0.003 mm steps = 301 frames, centred on the coherence peak
    # REPLACE the numbers with YOURS: --center is the peak position from the
    # coherence panel, --step-mm follows from YOUR measured coherence FWHM
    python oct_scan.py --center 12.3 --range-mm 0.9 --step-mm 0.003

    This saves a timestamped stack_*.npy plus a *_meta.json sidecar with the scan geometry into oct_scans/.

    Caution: oct_scan.py saves the stack as a .npy that is several GB in size (full resolution × hundreds of frames) — make sure you have disk space for it before starting the scan.
  6. Run oct_process.py to recover a depth map from the scanned stack (it computes the mean-diff interference amplitude at every pixel and takes the depth of maximum contrast):
    python oct_process.py --stack oct_scans/stack_<timestamp>.npy
    Tip: if you saved the stack at the original size and find the processing too slow, you can spatially downsample:
    python oct_process.py --stack oct_scans/stack_<timestamp>.npy -n 2
    # -n 2 spatially downsamples 2x2 — purely to make the computation faster

    It saves *_depth.npy, *_maxamp.npy, and a rendered *_render.png. To re-render with different color limits later, edit the settings at the top of oct_view.py and run it. A successful scan looks like this:

    Clean depth map of the coin: smooth gradient with the relief clearly resolved
    Depth map from a well-centred scan — the coin's relief is cleanly resolved over a smooth tilt gradient. (This coin is from another experiment, so it won't match the one in the photos above.)
    Caution: if your depth map looks like either of the ones below, part of the surface fell outside the scanned range — pixels clip at the first/last frame (flat saturated patches) or never see a peak at all (salt-and-pepper noise). Re-run the scan with a longer range and/or a better-centred start position.
    Depth map with large clipped yellow regions: scan range too short on one side Depth map that is mostly speckle noise: scan range largely missed the surface
  7. (Optional) To see what the processing sees, render a side-by-side movie of your scan with oct_contrast_video.py: the raw captured frames on the left, and the compute_mean_diff interference amplitude |I − DC| on the right — the quantity whose per-pixel argmax becomes the depth map. Watch the bright band of contrast wash across the coin as the stage sweeps through each surface depth:
    python oct_contrast_video.py -s oct_scans/stack_<timestamp>.npy
    # -n 2 downsamples for speed; --fps and -o control the output movie
    videoRaw frames (left) vs interference amplitude (right) for a coin scan — the contrast band sweeping across the surface is the coherence gate crossing each pixel's depth; its per-pixel peak position is the depth map.

5Scanning other objects

Replace the coin with another object (or coin) of your choice and try doing the same!

Bonus: depth_to_pointcloud.py converts a depth map (plus its metadata sidecars) into a 3D point cloud you can inspect interactively.

Software installations

The rig is a Thorlabs KDC101-driven motorized stage plus a monochrome machine-vision camera — either Allied Vision (via the Vimba X SDK) or IDS uEye (via pyueye). All the software runs from one Python environment.

1 · Getting the code

All the scripts live in the same repository as this page — github.com/cmu-ci-lab/summer-school — so if you're reading this locally, you already have them. If not:

git clone https://github.com/cmu-ci-lab/summer-school.git
cd summer-school

If fixes are posted during the course, git pull picks them up.

Heads-up — Python required: everything runs from Python inside a virtual environment. If possible, have Anaconda (or Miniconda/Miniforge) installed before the course — that's the smoothest path. No conda? A plain Python 3 install works too: setup.sh will build a .venv instead.

2 · Python environment: setup.sh

setup.sh prefers conda if you have it and falls back to a plain .venv if you don't. On macOS run it from the Terminal; on Windows, run it from Git Bash (installed with Git for Windows, which you likely already have):

chmod +x setup.sh
./setup.sh
# or, to force a venv even when conda exists:
FORCE_VENV=1 ./setup.sh
What the script actually does (it's safe to re-run any time — every step is skipped if already done, and nothing outside the repo and the iccp-oct environment is touched):
  • creates or updates the iccp-oct environment from environment.yml (Python 3.12, numpy, scipy, matplotlib, OpenCV, pyserial, pylablib, pyftdi);
  • searches the standard Vimba X install locations and installs the vmbpy wheel into the environment if the SDK is already present;
  • on macOS, installs libusb (conda-forge or Homebrew) — the stage backend;
  • registers a Jupyter kernel and prints the activation command for whatever it built.

3 · Alternative: manual conda install

If you'd rather not run the script and already have conda, create the environment directly from environment.yml:

conda env create -f environment.yml
conda activate iccp-oct

(You'll still need to install your camera's SDK/bindings by hand — see step 5 below — and on macOS the stage backend: conda install -c conda-forge libusb plus pip install pyftdi.)

4 · Activating the environment

# conda path
conda activate iccp-oct
# venv path
source .venv/bin/activate        # Windows: .venv\Scripts\activate

In VSCode, pick the interpreter with Ctrl/Cmd+Shift+P → Python: Select Interpreter → Python (iccp-oct) (or ./.venv). Some scripts can also be run cell-by-cell in the Interactive Window, so it's worth setting this up now.

5 · Camera SDK & platform notes

This last step depends on your platform and on which camera your rig has — only one camera entry applies to you.

Windows

  • Stage: install the Thorlabs Kinesis software from thorlabs.com/software-pages/motion_control (64-bit installer) — it provides the APT/FTDI driver (ftd2xx.dll) that the KDC101 is detected through; without it the stage will not be found even though Python is set up correctly. Unplug/replug the KDC101 USB cable after installing. The Kinesis GUI is also handy for debugging — but close it before running the scripts, since it holds the controller exclusively.
  • Allied Vision camera: vmbpy is not on PyPI — it ships as a wheel inside the Vimba X SDK. Install VimbaX_Setup-2026-1-Win64.exe, then re-run setup.sh (it finds and installs the wheel automatically), or install it yourself from C:\Program Files\Allied Vision\Vimba X\api\python\vmbpy-*.whl.
  • IDS camera: two pieces — the IDS Software Suite 4.97 for Windows 32/64-bit (the native uEye SDK and driver; without it pyueye imports but every call fails), then pip install pyueye into the project environment. If you're on IDS hardware you can skip Vimba X entirely; pass --camera ids to the hardware test so it doesn't check for vmbpy. Note: the IDS Software Suite has no macOS support — IDS cameras are Windows-only in this course (which is why the macOS section below has no IDS entry).
Caution: if you are using an IDS camera, do NOT install Vimba X — install only the IDS Software Suite. Installing Vimba X alongside the IDS setup can brick the system. Only install the SDK for the camera you actually have.

macOS

  • Stage: no driver needed. Apple's FTDI driver ignores the KDC101's custom PID (0xfaf0), so no /dev/cu.usbserial-* port ever appears — don't hunt for one; the stage is driven directly over libusb via pyftdi. setup.sh installs libusb (conda-forge or Homebrew) for you.
  • Allied Vision camera: install VimbaX_Setup-2023-4-macOS.dmg, then re-run setup.sh (it finds and installs the wheel automatically), or install it yourself from /Users/Shared/Allied Vision/Vimba X/Vmbpy/vmbpy-*.whl.
  • IDS camera: not supported on macOS (the IDS Software Suite is Windows-only in this course) — use a Windows laptop for IDS rigs.
Caution (macOS): the Vimba X installer package may be blocked by macOS when you run it. If that happens, open System Settings → Privacy & Security, scroll down to the message about the blocked installer, and click Open Anyway to allow it to run.
Where's the wheel? Once you install Vimba X, the vmbpy wheel is inside the installed SDK (at the paths listed above) — you don't download it separately. If you can't find it, run python test_hardware.py: it searches the standard install locations for you and prints the wheel's path along with the exact pip install command.
Tip: on either platform, install the vmbpy wheel into the project environment (activate iccp-oct or the venv first, then pip install <path-to-vmbpy-*.whl>) — not into the system Python, or imports will fail when you run from the project env.

Troubleshooting

Plain python fails (a ModuleNotFoundError for pylablib, cv2, vmbpy, … or the hardware suddenly "isn't found") — you're almost certainly running the wrong Python: base conda, the system interpreter, or some other environment that happens to be on your PATH. Two fixes:

  • With the iccp-oct environment active, call its interpreter explicitly:
    "$CONDA_PREFIX/bin/python" visualizer.py
  • Or use the ./python wrapper that ships with the repo — it always resolves this project's interpreter (the active iccp-oct env, then .venv, then iccp-oct in the usual conda install locations), no matter what shell state you're in:
    ./python visualizer.py
    ./python test_hardware.py

    If the wrapper is ever missing, a plain symlink to the env's interpreter does the same job:

    ln -sf "$CONDA_PREFIX/bin/python" python

pip installs "succeed" but the import still fails — same root cause: bare pip installed the package into whichever Python was on PATH, not the project env. Always install through the same interpreter you run with:

./python -m pip install <package>
# equivalently:
"$CONDA_PREFIX/bin/python" -m pip install <package>

For hardware problems (stage or camera not found), re-run ./python test_hardware.py — see step 1 of Part 2.