Skip to content

Commit

Permalink
Getting ready to make repo public
Browse files Browse the repository at this point in the history
  • Loading branch information
furrysalamander committed Apr 29, 2023
1 parent 24116d7 commit 2ee5e07
Show file tree
Hide file tree
Showing 29 changed files with 932 additions and 111 deletions.
6 changes: 3 additions & 3 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
"BUILDKIT_INLINE_CACHE": "0"
}
},
"runArgs": [
// "--device=/dev/video2"
],
// "runArgs": [
// "--device=/dev/video2"
// ],
"customizations": {
"extensions": [
"ms-python.python",
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,5 @@ frame_data/
*.gif
*.mp4
*.png
*.avi
*.avi
*.pkl
1 change: 1 addition & 0 deletions FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ko_fi: furrysalamander
674 changes: 674 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

101 changes: 99 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,99 @@
# rubedo

# Rubedo
## A tool for automatically calibrating pressure advance.
Do you hate manually tuning pressure advance? Me too! This script can use a line laser and camera that are attached to your printer's toolhead to generated a 3D model of a pressure advance calibration pattern, and estimate how badly each line is deformed. We can pick the best one, and use this as our pressure advance value.

![Printer with the system installed](graphics/COV_6803.jpg)
*The system installed on my printer*

![Calibration pattern compared with the control and results](graphics/calibration_control_calibrated.png)
*On the left is the pattern that was scanned. The middle pattern shows the pattern printed with pressure advance disabled, and the pattern on the right shows a pattern printed with the calibrated value.*

***
### Disclaimer
This tool is still very much experimental. I hope that someday it will grow to be more robust and user friendly, but right now you should know at least a little bit of Python if you're interested in trying it out. It's not perfect. The gcode that is generated may not play nicely with your printer. I would recommend keeping the emergency stop button within close reach the first time you run the calibration, just to be safe.
***

## Things you will need to setup on your printer:
* Klipper + Moonraker. This project communicates with moonraker using websockets.
* A high quality USB camera. I used the 1080p nozzle camera from 3DO. The exact model I have is no longer available for purchase, [but they do have a 4k version](https://3do.dk/59-dyse-kamera). You will need to adjust several of the constants in `constants.py` folder to adjust the system to work with whatever camera you choose. The most important thing here is that the camera can focus on things at close distances. I think it might be possible to get a modified endoscope working with the system, but I tried one without modifications and I just couldn't get enough detail to make this work well at the default focus distance.
* A line laser, preferably with an adjustable focus. I tested a fixed focus laser earlier on, but the beam was super thick and there was no way to adjust it. I have a laser like [this](https://www.amazon.com/module-Industrial-Module-adjustable-point-2packs/dp/B0BX6Q9FD8/). It's capable of focusing at a fairly close distance, though I had to put some hot glue on the lens to keep it from rotating over time since it's fairly loose at the distances I'm using it at. Also, the outer edges of the line are poorly focused because they're further away than the center of the line, but it hasn't been a huge issue since it's just important that the very center of the beam is very thin.
* `LASER_ON` and `LASER_OFF` macros. I have my laser connected to the SB2040 PCB on my toolhead. I used one of the 5v fan outputs, and it works fairly well.
* Some sort of a mount for your USB camera and laser. I am using the mount `3DO_Mount_v2.step` located in the `camera_mounts` directory. If you do not use this exact mount, you will need to adjust the constants for the X and Y camera offset. In addition, you will need to ensure that the laser is at a 45° angle from the camera. Ideally, both the camera and laser will be most in focus exactly where the laser passes through the center of the camera's field of view. This was not the case with my hardware, so I have shift the area my code analyzes to the side a bit. If your camera is rotated differently than mine, it should be fairly easy to add another parameter to rotate the video feed, but I have not implemented that yet so you will need to figure that out yourself. There might be a way to do that with the ffmpeg arguments used.

Here's a closeup of the system attached to my printer. I didn't have screws that were the right length, so I'm using some friction fit printed bushings and double sided tape to keep things together.

![toolhead with system attached](graphics/laser_mount.jpg)

## On whatever device you want to run the calibration from:
* Install ffmpeg
* Install the following python modules:
```
opencv-python-headless matplotlib aiohttp websocket-client
```
* Clone this repository
* Go through the `constants.py` file, and adjust whatever you need to in order to make it match the way your printer is configured.
### Configuring constants
Honestly, this is going to be a bit tricky, and is probably the most brittle part of the process. Some of the config options, such as the camera X and Y offset are fairly straightforward. However, the crop settings are crucial to getting the system to work well. These are used by the `crop_frame()` function in `processing.py`, and if the resulting frame does not clearly show the area where the laser is hitting the filament, then this will not work at all.
This image is what a cropped frame looks like on my machine. Perhaps I should shift the frame slightly further to the right. More experimentation needs to be performed to figure out exactly what the ideal setting is. The most important thing is that the camera should only see the line that it is currently scanning. You will need to take care to ensure that the line you're cropping to is not the wrong one.
<img src="graphics/cropped.png" width="135" height="160">
```
TODO Add a simple script for calibrating this setting
```
Assuming you are using an adjustable focus line laser, you will need to move the print head to the height where the print bed is perfectly in focus. Then, you will need to adjust the focus of the line laser until the laser beam is as thin as you can get it. Make sure that the beam is also perfectly parallel with the Y axis. This is necessary in order to get a good scan data.
# Code Organization
The code that allows for hands free calibration is in `main.py`. Once you've configured everything correctly, you should be able to run the script and get recommended pressure advance value. If you have `VALIDATE_RESULTS` enabled, the printer will print another two patterns, one w/ PA disabled, and another with the selected value. Most users probably won't want this, so feel free to turn it off. It also makes it hard to find the recommended value in the scripts output.
If you want to test a different range of pressure advance values, or change where the calibration pattern is printed, you can do so by editing the parameters used for the PatternInfo object created at the start of the main function. By default, the pattern is printed at (30, 30), and tests 10 PA values between 0 and 0.06.
### `pa.py`
This file contains code for generating the calibration pattern gcode.
### `record.py`
This file contains functions for recording video clips of the printed lines.
### `processing.py`
This file contains a few utilities that filter and transform the video frames to help minimize noise.
### `analysis.py`
This file contains the code that generates height maps for each line, and then computes the line deviation for each heightmap.
### `visualization.py`
This file contains several utilities for visualizing the height maps and data that is generated from the scans. Currently, some of the functions here are in disrepair, as I needed the output during early development, but haven't used them since.
### `generate_bulk_scans.py`
This file will print 27 copies of the calibration pattern, scan all of them, and then save the results for later analysis. I used this while working on my research paper for this project to measure how consistent the system was.
### `generate_report_data.py`
This script consumes the files generated by the `generate_bulk_scans.py` script, and generates height map visualizations for each line that was scanned. In addition, it generates charts that aggregate the data from all scans to visualize the overal consistency of the calibration process.
# Visualization
Here are some heightmaps from lines that I scanned while working on this project. They're a little bit squished together because the columns actually represent video frames, and not pixels or millimeters.
PA at 0.13
![0.13 line](graphics/2_0.013_color.png)
PA at 0.33
![0.33 line](graphics/5_0.033_color.png)
PA at 0.60
![0.60 line](graphics/9_0.060_color.png)
Here's what the 0.33 line looks like in 3D
![0.33 line 3D](graphics/5_0.033_3d.png)
# Results
I am working on a formal research paper that goes more in depth about the way the system works, but in the meantime, I have a video on youtube that gives an overview of everything.
# Additional Information
If you think this project is cool, there are a bunch of us on the Alchemical3D discord server that are working on a printer that will use this system. In addition, we are excited about the prospect of using it to calibrate other things, such as extrusion multiplier or even bed mesh. Feel free to check it out:
[https://discord.gg/ByyEByP7hp](https://discord.gg/ByyEByP7hp)
# Support This Project
Like this project? Feel free to make a donation.
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/L3L63ISSH)
2 changes: 1 addition & 1 deletion analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def brightest_average(pixel_values: np.ndarray):

def weighted_average(pixel_values: np.ndarray):
normalized_values = pixel_values / 255
adjusted_values = normalized_values ** 100
adjusted_values = normalized_values ** 10
x_values = np.arange(adjusted_values.size)
if adjusted_values.max() == 0:
# FIXME: I need an appropriate solution for what to do if there are no non-zero values.
Expand Down
1 change: 1 addition & 0 deletions camera_mounts/3DO_Mount_v2.step
80 changes: 77 additions & 3 deletions constants.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,82 @@
OUTPUT_GRAPH = False
OUTPUT_FRAMES = True
OUTPUT_HEIGHT_MAPS = False
# Connection details for communicating with the printer's moonraker API.
HOST = '192.168.1.114'
WS_PORT = 7125
GCODE_ENDPOINT = '/printer/gcode/script'
OBJECTS_ENDPOINT = '/printer/objects/query'

# This will print a calibrated + control pattern and measure the % improvement after tuning
VALIDATE_RESULTS = True

# Print settings
BUILD_PLATE_TEMPERATURE = 110
HOTEND_TEMPERATURE = 255
HOTEND_IDLE_TEMP = 200

# This is where the toolhead moves to indicate that it's done printing the PA pattern.
FINISHED_X = 30
FINISHED_Y = 250

# Any gcode you want to be sent before the pattern is printed.
# You could just have this call PRINT_START if you've configured
# that for your printer.
PRINT_START = f"""
M104 S180; preheat nozzle while waiting for build plate to get to temp
M140 S{BUILD_PLATE_TEMPERATURE};
G28
M190 S{BUILD_PLATE_TEMPERATURE};
QUAD_GANTRY_LEVEL
CLEAN_NOZZLE
G28 Z
M109 S{HOTEND_TEMPERATURE};
CLEAN_NOZZLE
"""

# Information about the USB camera mounted to the hotend.
VIDEO_DEVICE = "/dev/video2"
VIDEO_RESOLUTION = "1280x720"
FRAMERATE = "30"
# The camera's distance from the nozzle.
# This tells the recording code how to center the line within the camera's field of view.
# The offsets are in mm.
CAMERA_OFFSET_X = 28
CAMERA_OFFSET_Y = 50.2

# This is the height where the camera and laser are in focus.
LASER_FOCUS_HEIGHT = 17.86

# How the processing code finds the area of interest. Units are in pixels.
# The crop offsets specify the pixel that the box should be centered on.
CROP_X_OFFSET = 220
# In my case, the crop Y offset should be zero, but my offset Y value above is slightly off.
# You can kind of tweak these if you find that things aren't quite right.
CROP_Y_OFFSET = 11
# How big the area around the laser should be cropped to.
CROP_FRAME_SIZE_X = 45
CROP_FRAME_SIZE_Y = 60

# Sometimes ffmpeg is slow to close. If we start moving too early,
# we might accidentally record stuff we don't want to.
# I would like to eliminate these eventually by improving the video recording code.
FFMPEG_START_DELAY = 0.5
FFMPEG_STOP_DELAY = 0.6

# Pressure Advance Pattern Configuration
# This changes how the gcode for the pressure advance pattern is generated.
# Only edit this if you need to.
Z_HOP_HEIGHT = 0.75
LAYER_HEIGHT = 0.25
RETRACTION_DISTANCE = 0.5
EXTRUSION_DISTANCE_PER_MM = 0.045899
BOUNDING_BOX_LINE_WIDTH = 0.4 # May need adjustment.

# TODO: implement support for these.
# If we know the FOV, we can attach actual units
# to the values that are calculated.
# CAMERA_FOV_X = 0
# CAMERA_FOV_Y = 0

# These were used earlier on in development, but I need to re-implement
# them, as the refactor I did removed the code that made them work.
# OUTPUT_GRAPH = False
# OUTPUT_FRAMES = True
# OUTPUT_HEIGHT_MAPS = False
68 changes: 53 additions & 15 deletions generate_report_data.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import os
import pickle
import matplotlib.pyplot as plt
from pprint import pprint
import numpy as np
from pa_result import PaResult
from visualization import generate_color_map, generate_3d_height_map
import statistics
from matplotlib.colors import LinearSegmentedColormap
from scipy.stats import gaussian_kde
from collections import Counter
from pathlib import Path


def plot_scatter(data_clean: list[tuple[float, float]], dataset: str, ax):
Expand All @@ -29,12 +30,20 @@ def plot_scatter(data_clean: list[tuple[float, float]], dataset: str, ax):
ax.scatter(x, y, c=z)
ax.plot(trendline_x, np.poly1d(p)(trendline_x))
ax.set_xlabel("PA Value")
ax.set_ylabel("Score")
ax.set_ylabel("Deviation")

# add equation and R-squared annotation
equation = f'y = {p[0]:.0f}x^3 + {p[1]:.0f}x^2 + {p[2]:.0f}x + {p[3]:.0f}'
r2_text = f'R-squared = {r2:.2f}'
ax.annotate(equation + '\n' + r2_text, xy=(0.1, 0.95), xycoords='axes fraction', fontsize=12, ha='left', va='top')
# equation = f'y=({p[0]:.2e})x^3+{p[1]:.0f}x^2+{p[2]:.0f}x+{p[3]:.0f}'
equation = f"y=({p[0]:.2e})x^3" \
f"{'-' if p[1]<0 else '+'}{abs(p[1]):.0f}x^2" \
f"{'-' if p[2]<0 else '+'}{abs(p[2]):.0f}x" \
f"{'-' if p[3]<0 else '+'}{abs(p[3]):.0f}"

min_interpolated_value = trendline_x[np.argmin(np.poly1d(p)(trendline_x))]

min_text = f"Interpolated Minimum={min_interpolated_value:.3f}"
r2_text = f'R-squared={r2:.2f}'
ax.annotate(equation + '\n' + r2_text + '\n' + min_text, xy=(0.99, 0.98), xycoords='axes fraction', fontsize=9, ha='right', va='top')


def generate_consistency_chart(data_clean: list[tuple[float, float]], dataset:str, ax):
Expand All @@ -56,8 +65,10 @@ def generate_consistency_chart(data_clean: list[tuple[float, float]], dataset:st
# Calculate the standard deviation of the winning results
std_dev = statistics.stdev(winning_results)

# Add the standard deviation as a subtitle for the plot
ax.set_title(f"Standard deviation: {std_dev:.5e}", fontsize=10)
# Add the standard deviation as an annotation for the plot
ax.annotate(f"Standard deviation: {std_dev:.5e}", xy=(0.02, 0.95), xycoords='axes fraction',
fontsize=10, ha='left', va='top')

# fig.suptitle(dataset)
# return fig

Expand All @@ -82,7 +93,25 @@ def main():
fig_scatter, axs_scatter = plt.subplots(3, 4)
fig_bar, axs_bar = plt.subplots(3, 4)


fig_scatter.set_figheight(10)
fig_scatter.set_figwidth(16)
fig_bar.set_figheight(10)
fig_bar.set_figwidth(16)

pad = 5 # in points
cols = ['Black Filament, Ambient Light', 'Black Filament, No Ambient Light', 'White Filament, Ambient Light', 'White Filament, No Ambient Light']
rows = ['Matte Black', 'PEI', 'Textured']

for axs in [axs_bar, axs_scatter]:
for ax, col in zip(axs[0], cols):
ax.annotate(col, xy=(0.5, 1), xytext=(0, pad),
xycoords='axes fraction', textcoords='offset points',
size='large', ha='center', va='baseline')

for ax, row in zip(axs[:,0], rows):
ax.annotate(row, xy=(0, 0.5), xytext=(-ax.yaxis.labelpad - pad, 0),
xycoords=ax.yaxis.label, textcoords='offset points',
size='large', ha='right', va='center')

for i, dataset in enumerate(datasets):

Expand All @@ -95,21 +124,30 @@ def main():
row = i // 4
col = i % 4

plot_scatter(data_clean, dataset[1], axs_scatter[row, col])
plot_scatter(data_clean, '', axs_scatter[row, col])
generate_consistency_chart(data_clean, dataset[1], axs_bar[row, col])

# Set the same limits for the x and y axes of all subplots
axs_scatter[row, col].set_ylim(0, 350)

# axs_bar[row, col].set_xlim(xlim)
# axs_bar[row, col].set_ylim(ylim)
axs_scatter[row, col].set_ylim(0, 380)
axs_bar[row, col].set_xlim(0, 0.06)
axs_bar[row, col].set_ylim(0, 26)

for index, scan in enumerate(data):
print(f"{dataset[0]},{index},{scan[0]:.3f},{scan[1].score}")
# os.makedirs("scan_data_megadump/" + Path(dataset[0]).stem, exist_ok=True)
# chart = generate_3d_height_map(scan[1])
# chart.savefig("scan_data_megadump/" + Path(dataset[0]).stem + "/" + f"{index}_" + f"{scan[0]:.3f}_" + "3d.png")
# plt.close(chart)
# chart = generate_color_map(scan[1])
# chart.savefig("scan_data_megadump/" + Path(dataset[0]).stem + "/" + f"{index}_" + f"{scan[0]:.3f}_" + "color.png")
# plt.close(chart)

# Adjust the spacing between subplots
fig_scatter.tight_layout()
fig_bar.tight_layout()

# generate_3d_height_map(data[5][1])
# generate_color_map(data[5][1])
# fig_scatter.savefig("scan_data_megadump/fig_scatter.png")
# fig_bar.savefig("scan_data_megadump/fig_bar.png")

plt.show()

Expand Down
2 changes: 2 additions & 0 deletions graphics/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
!*.png
!*.jpg
Binary file added graphics/2_0.013_color.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added graphics/5_0.033_3d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added graphics/5_0.033_color.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added graphics/9_0.060_color.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added graphics/COV_6803.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added graphics/aggregate_bar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added graphics/aggregate_scatter.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added graphics/blurred.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added graphics/calibration_control_calibrated.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added graphics/cropped.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added graphics/laser_mount.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added graphics/laser_thick.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added graphics/processed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 1 addition & 8 deletions klipper/gcode.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
import json
import websocket
import random

# HOST = 'fluiddpi.local'
HOST = '192.168.1.113'
WS_PORT = 7125
GCODE_ENDPOINT = '/printer/gcode/script'
OBJECTS_ENDPOINT = '/printer/objects/query'
from ..constants import *

# Helper function to automatically generate the coordinate strings
# for G0 commands.


def format_move(x: float = None, y: float = None, z: float = None, f: float = None):
def format_string(value, prefix):
return f" {prefix}{value}" if value is not None else ""
Expand Down
Loading

0 comments on commit 2ee5e07

Please sign in to comment.