Sub-pixel tracking of an optical fiber tip in microscopy images: Challenges with reflections and drift using Lucas-Kanade

I am working on a closed-loop alignment system for an optical fiber using a microscope (Dino-Lite). The goal is to track the XY coordinates of the fiber tip with high precision to provide feedback to a piezo-stage controller.

The Setup:

  • Hardware: 3-axis piezo controller + Dino-Lite Edge microscope.

  • Software: Python 3.x + OpenCV using the calcOpticalFlowPyrLK (Lucas-Kanade) algorithm.

  • Environment: Microscopy images with specular reflections on the fiber surface and semi-transparent edges.

The Problem: My current implementation (code below) works for smooth movements, but I am facing three main issues:

  1. Drift: Over time, the tracking points tend to “slide” along the body of the fiber rather than staying locked on the very end of the tip.

  2. Specular Glints: Reflections on the glass surface often create “false features” that Lucas-Kanade tries to track, destabilizing the motion vector.

  3. Motion Inconsistency: When the fiber moves backwards or changes direction quickly, the tracker sometimes loses the tip and stays “stuck” on a background texture.

I have implemented a CLAHE (Contrast Limited Adaptive Histogram Equalization) pre-processing step and a median displacement filter to reject outliers, but the sub-pixel precision is still not robust enough for long-term alignment.

My Current Implementation:

Python

import cv2
import numpy as np
from typing import Optional, Tuple

class PuntaTracker:
    """
    Precision tracker for optical fiber alignment.
    Optimized for microscopy:
      - Resists focus changes and background noise.
      - Sub-pixel precision to avoid vibrations.
      - Motion coherence filter (ignores background motion).
    """

    def __init__(
        self,
        box_sizes=(12, 16, 20, 24),   
        search_radius=16,
        max_corners=8,              
        quality_level=0.06,         
        min_distance=5,
        block_size=7,
        lk_win_size=(21, 21),
        lk_max_level=3,
        lk_max_iter=30,
        lk_eps=0.01,                
        min_points=3,               
        max_jump_px=40.0,
        redetect_min_points=3,
    ):
        self.box_sizes = list(box_sizes)
        self.search_radius = int(search_radius)
        self.max_corners = int(max_corners)
        self.quality_level = float(quality_level)
        self.min_distance = float(min_distance)
        self.block_size = int(block_size)

        self.lk_params = dict(
            winSize=lk_win_size,
            maxLevel=int(lk_max_level),
            criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, int(lk_max_iter), float(lk_eps)),
        )

        self.min_points = int(min_points)
        self.redetect_min_points = int(redetect_min_points)
        self.max_jump_px = float(max_jump_px)

        self.active = False
        self.prev_gray = None
        self.points = None             
        self.leader_idx = None         
        self.last_tip: Optional[Tuple[int, int]] = None
        self.last_bbox: Optional[Tuple[int, int, int, int]] = None
        self.frame_count = 0
        self.clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8))

    def _preprocess(self, frame_bgr):
        gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)
        gray = cv2.GaussianBlur(gray, (5, 5), 0)
        return self.clahe.apply(gray)

    def _detect_features_in_roi(self, gray, bbox):
        H, W = gray.shape[:2]
        x, y, w, h = self._clamp_bbox(bbox[0], bbox[1], bbox[2], bbox[3], W, H)
        roi = gray[y : y + h, x : x + w]
        if roi.size == 0: return None, None

        pts = cv2.goodFeaturesToTrack(
            roi, maxCorners=self.max_corners, qualityLevel=self.quality_level,
            minDistance=self.min_distance, blockSize=self.block_size
        )

        if pts is not None and len(pts) >= self.min_points:
            criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 40, 0.001)
            pts = cv2.cornerSubPix(roi, pts, (5, 5), (-1, -1), criteria)
            pts[:, 0, 0] += x
            pts[:, 0, 1] += y
            return pts.astype(np.float32), (x, y, w, h)
        return None, None

    def update(self, frame_bgr):
        if not self.active or self.points is None or self.prev_gray is None:
            return None, None, "need_click"

        self.frame_count += 1
        gray = self._preprocess(frame_bgr)
        next_pts, st, _ = cv2.calcOpticalFlowPyrLK(self.prev_gray, gray, self.points, None, **self.lk_params)

        if next_pts is None or st is None or np.count_nonzero(st) < self.min_points:
            return self._attempt_redetect(gray, "lost")

        good_new = next_pts[st == 1].reshape(-1, 2)
        good_old = self.points[st == 1].reshape(-1, 2)
        
        if len(good_new) >= 2:
            displacements = good_new - good_old
            median_disp = np.median(displacements, axis=0)
            diff = np.linalg.norm(displacements - median_disp, axis=1)
            mask = diff < 3.5 
            if np.count_nonzero(mask) >= self.min_points:
                good_new = good_new[mask]
            else:
                return self._attempt_redetect(gray, "unstable_motion")

        ref_point = good_new[np.argmin(np.sum((good_new - self.last_tip)**2, axis=1))]
        new_tip = (int(ref_point[0]), int(ref_point[1]))

        jump = np.linalg.norm(np.array(new_tip) - np.array(self.last_tip))
        if jump > self.max_jump_px:
            return self._attempt_redetect(gray, "jump_detected")

        self.last_tip = new_tip
        self.points = good_new.reshape(-1, 1, 2).astype(np.float32)
        self.prev_gray = gray
        return self.last_tip, self.last_bbox, "ok"

    def _attempt_redetect(self, gray, reason):
        if self._redetect(gray):
            return self.last_tip, self.last_bbox, "recovered"
        self.active = False
        return None, None, reason

    def _clamp_bbox(self, x0, y0, w, h, W, H):
        x0 = max(0, min(x0, W - 1))
        y0 = max(0, min(y0, H - 1))
        w = max(10, min(w, W - x0))
        h = max(10, min(h, H - y0))
        return x0, y0, w, h

Questions:

  1. Is there a better alternative to goodFeaturesToTrack for objects with low contrast and transparency like an optical fiber?

  2. How can I improve the “lock” on the tip to prevent the points from sliding along the fiber body (drift)?

  3. Would a Template Matching approach or an Edge-Centroid algorithm be more stable than Lucas-Kanade for this specific microscopy use case?

Any advice or references to similar micro-tracking projects would be greatly appreciated!

Show us some pictures please.

Don’t let AI write your question.

The issue I’m having is that I can’t reliably track the tip of the fiber over time. During calibration, the fiber moves in different directions and I need to follow the tip to build a mapping between mm and pixels. I first tried using a CSRT tracker, but when the fiber moves forward, the tracker lags behind because the fiber looks very similar along its length, so it doesn’t detect the motion correctly. Then I tried Lucas-Kanade optical flow, but it also eventually loses the tip.

I’m not sure what the best approach is to keep tracking the tip robustly in this scenario.

if this is for a commercial application, you should go look for some company in your area that deals with Machine Vision. they will probably not use OpenCV, but some commercial software package that lets them prototype rapidly and graphically.

all the approaches you mentioned, you can discard. none of those will work, and some of them are not even plausible.

what I see in your literal photo of a laptop screen (I had hoped for images directly from the camera) looks like some milled surface, some small fiber in the foreground, and the lighting is absolutely terrible to work with for any Machine Vision application.

you will have to rethink the entire situation, consider changes to the situation. whatever that milled surface is, it needs to be illuminate differently. maybe the surface even has to be reworked to be smooth and dull/matte, or replaced/covered/coated by something. impossible to say specifically from just this picture.

you will need experienced engineers, on site, to solve this for you.

Thank you for your answer.

Sorry for the image I wasn’t able to go to the lab to take it.

The purpose of this program is to automatice an alignment system for the lab I work for. There I am developing my final project of my engineering degree. Could you please specify in the light problem? Because the camera has its own flash and I can set it brighter if that’s the problem.

Thank you for the surface tip it’s the first time I work with machine vision and I didn’t notice that it could interfere with the performance of the software from now on I will cover it.

I read about using DIS for the purpose, I don’t know do you have any tracking algorithm proposal or any possible way to achieve this?

Thank you very much again

Any success in MV depends on the image being a certain way so the MV can reliably capture objects. objects need to contrast against background. towards that, the background should be featureless, flat. foreground and background need to contrast. black and white. I’m not saying digital filters. I’m saying shadow and light.

optical flow: that’s not a way to track anything, it’s a component at best. optical flow alone will leave your results drifting.

feature matching: your glass fiber has no features. feature matching has nothing to hold onto.

generic tracking algorithms, similarly, will not know what to do with a featureless glass fiber. outputs from those, on this situation, will be unreliable to the point of useless.