Why I Built This
In tribological testing (evaluating friction, wear and lubrication on ball-on-disc or pin-on-disk test rigs), measuring the wear scar on a metallic or ceramic test specimen is critical for calculating the wear volume and anti-wear properties of lubricants.
Traditionally, measuring a wear scar is done manually: an operator stares into an optical microscope, manually drags digital calipers across the circular or elliptical crater, and reads the crosshair measurements. This has severe drawbacks:
- Slow and subjective: Measurement repeatability depends heavily on operator fatigue and manual judgment.
- Failure of classical edge detection: Classic thresholding (Otsu, adaptive) and Canny edge detectors struggle because metallic specimens suffer from specular reflections, microscopic grinding scars, variable surface textures, and inconsistent microscope illumination.
To solve this, I designed and deployed an automated optical measurement pipeline: a high-resolution digital microscope takes the picture, a custom-trained YOLO neural network locates the exact scar boundary, and LabVIEW drives the test sequence over a deterministic byte-level binary TCP protocol, converting pixel dimensions to calibrated micrometres (μm).
The 6-Step Implementation Pipeline
- Capturing high-resolution images from the microscope camera via DirectShow
- Annotating polygon contours using LabelMe
- Converting annotations and fine-tuning YOLOv5 to export
best.pt - Inference pipeline, Non-Maximum Suppression (NMS), and pixel-to-micrometre optical scaling
- Freezing Python into headless, standalone Windows executables (PyInstaller)
- Controlling the executables from LabVIEW using a robust, big-endian binary TCP protocol
How the Pieces Fit Together
LabVIEW is the single master executive on the test bench. It knows the exact test sequence, motor positions, and cycle counts. It spawns two independent Python background processes as standalone executables and commands them over TCP sockets.
System Architecture: LabVIEW Master · 2 Python EXEs · best.pt
Step 1: Capturing Images with a Python Live-Stream App
Every high-performance model depends on a rock-solid, consistent dataset. The first application I wrote (livestream10.py) has one critical mission: display the live microscope camera stream and snap an uncompressed, crystal-clear frame whenever LabVIEW issues the trigger.
The dataset goal was 100 to 500 images acquired at calibrated optical magnification and fixed resolution (up to 3840 × 2880), saved with specimen and test IDs.
Internal Architecture:
- Camera Worker Thread: Runs in a dedicated
QThreadusingcv2.VideoCapture(..., cv2.CAP_DSHOW). The thread maintains a single-frame buffer, ensuring LabVIEW never receives a stale frame from hardware queues, and automatically triggers auto-reconnect if 15 consecutive reads fail. - Frameless Always-on-Top Window: Boots in hidden mode so the executable lives in the background without stealing operator focus until LabVIEW sends a show/restore command.
- Non-blocking TCP Server (Port 5006): A daemon listening thread parses byte commands and uses Qt Signals to cross thread boundaries safely to the GUI.
Three Production Tweaks that Solved Rig Instability
1. Fast Mode Probing: Calling OpenCV's resolution probes sequentially causes driver deadlocks on DirectShow USB microscope cameras. Instead, I query supported sensor resolutions directly through QCameraInfo from the Windows kernel and fall back to DirectShow only if needed.
2. Hidden Frame Capturing: Every incoming frame is stored in a thread-safe last_frame buffer before UI rendering. Therefore, an image save request succeeds with zero frame drops even if the GUI window is minimized.
3. Camera Release on Minimize: Minimizing the window suspends the capture thread and releases the hardware lock on the USB sensor, cooling down the CMOS sensor during prolonged idle cycles.
Camera Command Set (TCP Port 5006)
| Command | Hex Code | Payload | Operational Description |
|---|---|---|---|
| Connect Camera | 0x01 | Camera index (e.g. 0) | Probes resolutions, opens DirectShow stream, selects max mode |
| Start Stream | 0x02 | None | Activates acquisition thread and displays frameless view |
| Stop Stream | 0x03 | None | Stops capture worker and hides GUI |
| Grayscale Mode | 0x04 | None | Switches capture stream to single-channel 8-bit mono |
| RGB Mode | 0x05 | None | Switches stream to full 24-bit RGB color |
| Minimize | 0x07 | None | Releases camera hardware lock and hides window |
| Close App | 0x08 | None | Clean process termination (only if authorized) |
| List Modes | 0x12 | None | Replies 0x90 with supported W x H resolution modes |
| Set Mode | 0x13 | Mode index | Applies selected resolution profile |
| Restore | 0x14 | None | Re-locks camera and restores GUI to foreground |
| Save Image | 0x15 | Full filepath | Flushes last_frame to disk with cv2.imwrite() |
The core socket receiver ensures packet boundary integrity by enforcing a 3-byte header protocol:
# Big-endian packet parsing with socket framing protection
header = conn.recv(3)
cmd = header[0]
payload_len = int.from_bytes(header[1:3], "big")
# Enforce stream completeness
payload_raw = self.recv_exact(conn, payload_len)
payload = payload_raw.decode("utf-8", errors="ignore")
Step 2: Annotating the Scar with LabelMe
Because metallic wear scars have irregular, semi-circular or elliptical geometry with feathering at the boundaries, rectangle bounding boxes during annotation are insufficient. I used LabelMe to annotate the true boundary as a multi-point closed polygon.
{
"shapes": [
{
"label": "scar",
"points": [
[412.3, 188.7], [430.1, 175.2], [455.8, 172.0],
[468.4, 190.5], [462.0, 215.9], [430.6, 222.1],
[410.2, 205.4]
],
"shape_type": "polygon"
}
],
"imagePath": "specimen_014.jpg",
"imageHeight": 480,
"imageWidth": 640
}
Golden Rules for Industrial CV Datasets
- Tight Boundary Tracing: The neural network's localization precision can never exceed the accuracy of the manual training ground truth.
- Immutable Class Typography: Strict case normalization (e.g. always
"scar", never"Scar"or"SCAR") prevents phantom classes. - Optical Diversity: Include specimens with different reflectivities, oil films, scratches, and variable microscope light angles to prevent the model from memorizing specific illuminations.
Converting LabelMe JSON to YOLOv5 Format
YOLO requires normalized coordinates in a companion .txt file matching each image:
<class_id> <x_center> <y_center> <width> <height> normalized from 0.0 to 1.0.
width = (x_max - x_min) / W height = (y_max - y_min) / H
0 0.6864 0.4105 0.0909 0.1044Step 3: Training YOLOv5 and Exporting best.pt
With images and normalized labels partitioned 80/20 into train/validation folders, fine-tuning is launched:
python train.py --img 640 --batch 16 --epochs 300 \
--data dataset.yaml --weights yolov5s.pt --cfg yolov5s.yaml
The secret to achieving >98% mAP with only a few hundred images was transfer learning from yolov5s.pt pretrained on MS COCO. The lower convolutional layers already understand edge gradients, texture contrasts, and elliptical profiles.
Network Architecture Deep Dive:
- Backbone (CSPDarknet53): Spatial resolution is downsampled across 5 pyramid stages (640 → 320 → 160 → 80 → 40 → 20) while channel depth scales up. Early layers capture high-frequency wear scar scratches; deeper layers capture macro circular geometry. An SPPF (Spatial Pyramid Pooling - Fast) layer aggregates multi-scale receptive fields.
- Neck (PANet): Path Aggregation Network merges bottom-up detail features with top-down semantic features, maintaining spatial localization accuracy.
- Head: Evaluates three anchor scales with CIoU (Complete Intersection over Union) bounding box regression loss, penalizing aspect ratio discrepancies.
- Augmentation: Mosaic 4-image stitching, random scaling, and color/brightness jitter simulate fluctuating lab lighting conditions.
Why Deploy best.pt Over last.pt?
During a 300-epoch run, last.pt merely represents the final epoch, which frequently suffers from minor overfitting. In contrast, best.pt preserves the checkpoint that achieved maximum validation fitness on unseen test data across mAP@0.5:0.95.
Step 4: Sub-Micrometre Optical Calibration
Neural networks output pixel coordinates. To obtain engineering units for ISO/ASTM wear scar standards, we must calibrate pixel distance against a certified optical stage micrometer:
The Viewer and Measurement TCP Daemon (Port 5007)
A second PyQt5 application opens the captured image, displays the bounding ellipse and ROI overlay, and exposes measurement endpoints to LabVIEW:
| Group | Hex Commands | Function |
|---|---|---|
| Image | 0x01, 0x02 | Open image file, Fit image to window |
| ROI | 0x03, 0x04, 0x14, 0x12 | Draw rectangle/line, Resize ROI, Clear all annotations |
| Zoom | 0x06, 0x07, 0x08 | Zoom in, Zoom out, Absolute zoom percentage |
| Measurements | 0x13 | Return JSON payload with Major, Minor, Mean (μm) |
| Replies | 0x80 (ACK), 0x81 (Error) | Deterministic handshake response to LabVIEW |
Step 5: Packaging Standalone Windows EXEs
Factory test benches and customer PC deployments must never depend on manual Python environments, CUDA toolkits, or pip dependencies. Both applications are compiled into self-contained Windows binaries using PyInstaller:
pyinstaller --noconfirm --onefile --noconsole livestream10.py
Critical Windows Multiprocessing Fix
When compiling Python scripts that spawn threads or subprocesses on Windows, failing to include multiprocessing.freeze_support() causes compiled binaries to spawn infinite fork-bomb loops on execution!
import multiprocessing
if __name__ == '__main__':
multiprocessing.freeze_support()
Step 6: Driving the Entire Sequence from LabVIEW
LabVIEW initiates the test run, uses System Exec to launch the background executables, and connects via standard LabVIEW TCP sockets.
Binary Packet Specification:
| Field | Length | Type | Description |
|---|---|---|---|
| CMD | 1 Byte | U8 | Command opcode (e.g. 0x01, 0x15) |
| LENGTH | 2 Bytes | U16 (Big-Endian) | Payload byte count (MSB first) |
| PAYLOAD | N Bytes | UTF-8 String | Filepath, JSON parameter, or empty |
Example: Sending filepath C:\LAB-IQ 150\images\test.png (29 bytes = 0x001D) with command 0x01:
01 00 1D 43 3A 5C 4C 41 42 2D 49 51 20 31 35 30 5C 69 6D 61 67 65 73 5C 74 65 73 74 2E 70 6E 67
End-to-End Automated Test Sequence:
- Launch & Handshake: LabVIEW starts camera daemon, connects to
localhost:5006. - Hardware Init: LabVIEW transmits
0x01(Connect Camera) →0x02(Start Stream). - Trigger Capture: Specimen reaches cycle count; LabVIEW sends
0x15(Save Image) with file target. - Release Resource: LabVIEW transmits
0x07(Minimize) to release USB DirectShow handle. - Inference & Measure: LabVIEW sends image path to detection viewer (
0x01), then requests results (0x13). - Report Generation: LabVIEW reads the JSON reply (
{"major_um": 421.8, "minor_um": 415.2, "mean_um": 418.5}) and appends values to the certified ASTM test certificate.
Key Engineering Takeaways
- Dataset Quality > Model Architecture: Eliminating reflections and standardizing magnification outperformed tuning hyperparameters by an order of magnitude.
- Pretrained Weights are Mandatory: Fine-tuning pretrained COCO weights allowed training to converge in under 45 minutes on a standard laptop GPU.
- Binary TCP beats REST/HTTP: In industrial environments, a 3-byte binary TCP header eliminated HTTP latency, parsing overhead, and third-party web server dependencies.
- Decoupled Architecture: Keeping machine vision in Python and hardware sequencing in LabVIEW leverages the best strengths of both platforms without monolithic lock-in.