Calibration of the motion axes of a 3D printer (steps/mm, backlash, and XY skew / orthogonality) without a dial indicator and without an encoder - using a single camera and a chessboard target as a virtual encoder. The result is validated by running an elliptical toolpath and measuring the deviation from ground truth.
Clone:
git clone https://github.com/ZaydZaidan/3D-Printer-Calibration-Using-Virtual-Encoder.gitStart here:
docs/00_handoff_guide.md- a step-by-step handoff guide with the expected output of every stage.
Machine calibration (linear test, 10 mm commanded move, 10 valid samples out of 40 trials):
| Metric | Baseline | After calibration | Improvement |
|---|---|---|---|
| X axis error | 0.0463 mm | 0.0071 mm | 84.6 % |
| Y axis error | 0.0550 mm | 0.0054 mm | 90.2 % |
| Y drift while moving X | 0.0937 mm | 0.0043 mm | 95.4 % |
| X drift while moving Y | 0.0898 mm | 0.0036 mm | 96.0 % |
Starting parameters: steps/mm X 80, Y 80; backlash comp 0; skew comp 0. Final parameters: steps/mm X 79.72, Y 80.40; backlash X 13.3 µm, Y 31.4 µm; skew -9.2 µm/mm (X-on-Y), 9.213 µm/mm (Y-on-X) - about ±0.52°.
Elliptical toolpath evaluation (RMSE against ground truth):
| Metric | Baseline | After calibration |
|---|---|---|
| RMSE X | 76.91 µm (std 5.33) | 34.42 µm (std 1.61) |
| RMSE Y | 65.29 µm (std 4.67) | 30.75 µm (std 1.30) |
| RMSE total | 100.89 µm (std 7.00) | 46.17 µm (std 1.68) |
Total trajectory accuracy improvement: 54.24 %. Every number above was regenerated by re-running the notebooks in this repository, not copied from the thesis manuscript.
About the final values - important. 79.72 / 80.40 is not the result of a single
calculation. The calibration is an iterative loop: each round, the newly recommended
parameters are written back into the machine, a fresh dataset is captured, the error is
measured again, and so on until the error stops moving (steady state) and has fallen to
the order of the measuring instrument's own measurement error.
The chain: 80.00 (baseline) -> 79.6314 (round-1 recommendation) -> several intermediate
rounds -> 79.72 (final). So if you run the notebooks on the included dataset and get a
recommendation of 79.6314, that is not a bug - it is round one, not the final value.
Full procedure: docs/00_handoff_guide.md Step 2b.
Measurement error (validation against a dial indicator, 0.5 mm commanded move, 5 iterations): the mean absolute difference between the virtual-encoder reading and the dial indicator is 4.96 µm (X axis combined) and 7.81 µm (Y axis combined) - inside the ±10 µm resolution of the dial indicator itself.
[1] CAMERA calibration
EDLines -> line screening -> subpixel corner refinement -> corner sorting (homography)
-> cv2.calibrateCamera -> intrinsic matrix K + distortion coefficients D
(output: data/processed/camera_calibration/*.csv)
|
v
[2] MACHINE calibration (per-axis linear test)
Dataset from automated_linear_test.py, starting parameters 80/80/0/0
-> solvePnP per image -> dX/dY, backlash, skew
-> firmware recommendations M92 (steps/mm), M425 (backlash), M852 (skew)
|
v
[2b] Apply the recommendation, capture a new dataset, measure again
-> repeat until the error reaches steady state
|
v
[3] Run the elliptical toolpath with the new parameters
-> record video of the calibrated toolpath (baseline toolpath recorded earlier)
|
v
[4] Virtual Encoder
Every video frame: detect the target -> solvePnP -> tvec
-> position relative to the first valid frame -> trajectory X(t), Y(t) in mm
-> export CSV
|
v
[5] Trajectory evaluation
Ground truth = 2 intersecting ellipses (45 deg and 135 deg), forward + backward
Nearest neighbour (cKDTree) -> RMSE X, Y, total in micrometres (µm)
Baseline vs calibrated -> percentage improvement
[6] Measurement error (separate experiment, validated with a dial indicator)
Baseline parameters, commanded move reduced to 0.5 mm
-> compare the virtual-encoder reading against the dial indicator
| Stage | File | Input | Output |
|---|---|---|---|
| 1 | 01_camera_calibration/camera_calibration.ipynb |
chessboard images | camera_matrix.csv, dist_coeffs.csv, camera_params.csv, reprojection_rmse.csv |
| 2 | 02_machine_calibration/machine_calibration.ipynb |
K/D + oriy/fwd/bck dataset |
M92/M425/M852 recommendations, visualization_*.png |
| 3 | 03_virtual_encoder/virtual_encoder.ipynb |
pose_video_XX.avi |
trajectory CSV + comparison figures |
| 4 | 04_trajectory_evaluation/trajectory_evaluation.ipynb |
two trajectory CSVs | evaluation figures, RMSE in µm, % improvement |
| 6 | 06_measurement_error/plot_measurement_error.py |
dial_vs_ve.csv |
4-panel figure + MAE table |
Supporting files:
config_paths.py- every dataset path lives here. Check your data withpython config_paths.py.calibration_pipeline.py- the corner-detection functions (EDLines, screening, subpixel refinement, sorting) shared by all notebooks.05_data_acquisition/- scripts that require the machine and camera (see the README there).
The raw data (images, videos, raw CSVs - about 1.9 GB) is not committed to git because of GitHub's 100 MB per-file limit. It is available here:
Google Drive: https://drive.google.com/drive/folders/1-t_4XDX1aLUvNeTrkfXd1f1-iWcVR9tP?usp=sharing
The folder is named
rawand contains the full contents ofdata/raw/.
Download it and place the folder as data/raw/ inside your clone. Then verify with
python config_paths.py - every line should read OK.
Expected contents, useful for checking that nothing is missing after downloading:
| Folder | Files | Size |
|---|---|---|
camera_calibration |
107 | 19.2 MB |
circular_baseline |
20 | 4.3 MB |
circular_calibrated |
33 | 8.4 MB |
linear_baseline_x |
120 | 22.0 MB |
linear_baseline_y |
120 | 22.0 MB |
linear_calibrated_x |
120 | 22.4 MB |
linear_calibrated_y |
120 | 21.4 MB |
measurement_error |
361 | 68.2 MB |
noise_static_capture |
24 | 4.2 MB |
video_baseline_x |
21 | 1016.0 MB |
video_baseline_y |
1 | 35.6 MB |
video_calibrated_x |
11 | 573.1 MB |
video_calibrated_y |
1 | 38.9 MB |
video_kalibrasi_x |
3 | 104.2 MB |
| Total | 1062 | 1960 MB |
measurement_error holds six subfolders: x/y_machine_movement (120 + 120),
x/y_camera_calibration (54 + 31), and x/y_dial_gauge (18 + 18 dial indicator photos).
noise_static_capture holds the 24 stills of the stationary capture sequence described in
data/MANIFEST.md section 3.1. It is needed by
07_paper_figures/verify_environmental_noise.py, which re-derives the environmental-noise
figure from the raw images.
The mapping between the original folder names in the thesis and the names used here is in
data/MANIFEST.md.
Folder Rapih Code & Data Thesis/
|- README.md
|- config_paths.py <- all dataset paths (run it to check your data)
|- calibration_pipeline.py <- shared corner-detection functions
|- requirements.txt
|- 01_camera_calibration/camera_calibration.ipynb
|- 02_machine_calibration/machine_calibration.ipynb
|- 03_virtual_encoder/virtual_encoder.ipynb
|- 04_trajectory_evaluation/trajectory_evaluation.ipynb
|- 05_data_acquisition/ <- requires the machine and camera
|- 06_measurement_error/plot_measurement_error.py <- dial indicator vs virtual encoder
|- 07_paper_figures/ <- the scripts that draw every paper figure
|- Overleaf Latex Paper/ <- the manuscript (LaTeX) + REVISION_NOTES.md
|- data/
| |- MANIFEST.md <- original folder name -> repo name mapping
| |- raw/ <- raw data + video (1.9 GB, NOT in git)
| \- processed/ <- output of each stage (in git)
|- figures/ <- per-trial evaluation plots
| \- paper/ <- every figure used in the paper (PNG + PDF)
\- docs/
|- 00_handoff_guide.md <- HANDOFF GUIDE (start here)
|- 01_code_audit.md <- audit of the original code + cleanup plan
|- 02_file_roles.md <- role of every research file
\- 03_results_and_data_locations.md <- result numbers and data locations
figures/paper/ holds the exact figure files that go into the paper (PNG + PDF), and
07_paper_figures/ holds the scripts that produce them. Each script takes its numbers from the
tables of the thesis and writes into figures/paper/:
| Script | Produces |
|---|---|
07_paper_figures/plot_axis_iterations.py |
x_axis_iterations, y_axis_iterations, mae_summary |
07_paper_figures/plot_trajectory_rmse.py |
trajectory_rmse_iterations_final, trajectory_summary_bars_final |
07_paper_figures/plot_environmental_noise.py |
environmental_noise_with_std (legend revised: SD of the readout, not of |delta|) |
06_measurement_error/plot_measurement_error.py |
1_4_position_comparison_combined |
07_paper_figures/verify_environmental_noise.py re-derives the environmental-noise values from
the raw stills instead of trusting the hard-coded array, and prints both statistics definitions
side by side. Run it (python verify_environmental_noise.py) to audit that figure.
The three
plot_*.pyfiles are faithful copies of the author's original plotting code fromAfter Lulus Paper/code/: data, layout and labels are untouched — only the comments were translated and the output folder redirected into the repo. All 14 files infigures/paper/were regenerated from these scripts (they were pixel-for-pixel identical to the originals at the time of the copy).Two of them were then revised deliberately and no longer match the original renders:
environmental_noise_with_std(the legend reports the SD of the readout instead of the SD of the absolute deviations) and1_4_position_comparison_combined(panel annotationMAEinstead ofMean Diff). Both were re-rendered at 300 dpi. Details inOverleaf Latex Paper/REVISION_NOTES.mdanddocs/03_results_and_data_locations.mdsection 6.
Overleaf Latex Paper/ is the current manuscript as a self-contained LaTeX project:
main_new.tex (monolithic), Bibliography.bib, IEEEtran.cls, the eleven figures it includes
in images/, and main_new.pdf built from that source. To rebuild the PDF:
pdflatex main_new.tex && bibtex main_new && pdflatex main_new.tex && pdflatex main_new.texREVISION_NOTES.md in the same folder lists every
value that was checked against the raw data and corrected, with the derivation of each new
number: the dial-gauge comparison is reported as a mean absolute discrepancy (4.96 / 7.81 µm)
rather than the magnitude of a mean offset; the stationary-noise numbers use a single definition
(mean absolute deviation 2.58 / 4.97 µm, SD of the repeated readout 2.63 / 3.44 µm); the
distortion vector lists all five calibrated coefficients including k3 = -5.0330; and the
uncertainty budget was rebuilt (u_c 6.23 / 6.79 µm, U 12.46 / 13.57 µm at k = 2).
The manuscript supersedes the older thesis chapter under
Semester 8/Template_Skripsi_latex/. Where the two disagree, the manuscript and this repository are authoritative.
pip install -r requirements.txt
python config_paths.py # check that every dataset is foundcv2.ximgproc (EdgeDrawing) is only available in opencv-contrib-python, not in
plain opencv-python. Versions used in this research: Python 3.11.9,
opencv-contrib-python 4.12.0.88, numpy 2.2.6, scipy 1.17.1, pandas 3.0.2,
matplotlib 3.10.8.
Run each notebook from inside its own folder so that config_paths.py at the repo root
can be found.
Pick the test mode with an environment variable, without editing any notebook:
set KALIBRASI_MODE=baseline # Windows (baseline | calibrated | measurement_error)
export KALIBRASI_MODE=baseline # Linux / macOS| Mode | Dataset | steps/mm | target |
|---|---|---|---|
calibrated (default) |
linear_calibrated_x/y |
79.72 / 80.40 | 10 mm |
baseline |
linear_baseline_x/y |
80 / 80 | 10 mm |
measurement_error |
measurement_error/*_machine_movement |
80 / 80 | 0.5 mm |
Detailed per-stage instructions, expected outputs, and known limitations are in
docs/00_handoff_guide.md.