diff --git a/README.md b/README.md index b6a5bfb..53614ab 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # drawing-to-fsd-layout -A small script that converts a hand drawn track layout to a track layout that can be used in in Formula Student Driverless Simulators +A tool that converts a hand drawn track layout to a track layout that can be used in in Formula Student Driverless Simulators @@ -13,3 +13,23 @@ Clear hand-drawn tracks should also work. They do not have to be filled in. There is now also a canvas option in the script. The extracted track can be downloaded as a JSON file with x,y and color values are available for each cone, as well as an LYT file that can be used in Live for Speed. + +The tool is hosted on Streamlit Cloud and can be accessed [here](https://drawing-to-fsd-layout.streamlit.app/). + +## Installation + +If you want to run the tool locally you can follow these steps: + +```bash +git clone https://github.com/papalotis/drawing-to-fsd-layout.git + +cd drawing-to-fsd-layout + +# optional: create a virtual environment + +pip install -r requirements.txt + +streamlit run streamlit_app.py +``` + +The tool will be available at `http://localhost:8501` \ No newline at end of file diff --git a/drawing_to_fsd_layout/canvas_image.py b/drawing_to_fsd_layout/canvas_image.py index 54db158..64dfea7 100644 --- a/drawing_to_fsd_layout/canvas_image.py +++ b/drawing_to_fsd_layout/canvas_image.py @@ -1,19 +1,26 @@ -from drawing_to_fsd_layout.image_processing import Image -from streamlit_drawable_canvas import st_canvas -import streamlit as st import numpy as np +import streamlit as st +from streamlit_drawable_canvas import st_canvas + +from drawing_to_fsd_layout.image_processing import Image + def show_canvas_warning(): st.warning("You need to draw a track in order to continue.") st.stop() + def get_canvas_image() -> Image: - stroke_width = st.slider("Stroke width", 1, 25, 10) + should_erase = st.radio("Eraser", ["Off", "On"], horizontal=True) + + stroke_color = "white" if should_erase == "On" else "black" + canvas_result = st_canvas( stroke_width=stroke_width, - drawing_mode='freedraw', + stroke_color=stroke_color, + drawing_mode="freedraw", key="canvas", ) @@ -21,11 +28,15 @@ def get_canvas_image() -> Image: show_canvas_warning() # by default canvas changes the alpha channel, not the rgb channels + raw_image_data = canvas_result.image_data image_data = canvas_result.image_data[:, :, [-1]] + image_lightness = np.max(raw_image_data[:, :, :-1], axis=2) > 0 + + image_data[image_lightness] = 0 + image_data = np.broadcast_to(image_data, image_data.shape[:-1] + (3,)) - image_data = 255 - image_data if np.all(image_data == 255): show_canvas_warning() - + return image_data diff --git a/drawing_to_fsd_layout/common.py b/drawing_to_fsd_layout/common.py index 73b4614..6938928 100644 --- a/drawing_to_fsd_layout/common.py +++ b/drawing_to_fsd_layout/common.py @@ -1,5 +1,36 @@ +from pathlib import Path + import numpy as np FloatArrayNx2 = np.typing.NDArray[np.float64] IntArrayN = np.typing.NDArray[np.int64] FloatArray2 = np.typing.NDArray[np.float64] + + +def find_github_link_of_repo() -> str: + """ + Find the github link of a repository by looking at the .git/config file + + Args: + git_directory: The directory of the git repository + + Returns: + The github link of the repository + """ + + git_directory = Path(__file__).parent.parent + + # The entire function was generated by github copilot + git_config = git_directory / ".git" / "config" + with open(git_config, "r") as f: + lines = f.readlines() + + for i, line in enumerate(lines): + if "url = " in line: + url = line.split(" = ")[1].strip() + url = url.replace("git@", "https://") + url = url.replace(".git", "") + url = url.replace(".com:", ".com/") + return url + + raise ValueError("Could not find the github link in the .git/config file") diff --git a/drawing_to_fsd_layout/export.py b/drawing_to_fsd_layout/export.py index 01cd38a..2f035c5 100644 --- a/drawing_to_fsd_layout/export.py +++ b/drawing_to_fsd_layout/export.py @@ -248,21 +248,3 @@ def cones_to_lyt( bytes_to_write = _traces_to_lyt_bytes(cones_per_type, offset) return bytes_to_write - - -# chrono json - - -def export_for_chrono_json_str( - cones_left: FloatArrayNx2, - cones_right: FloatArrayNx2, -) -> str: - all_left = cones_left.tolist() - all_right = cones_right.tolist() - return json.dumps( - { - "blue": all_left[1:], - "yellow": all_right[1:], - "orange_big": all_left[:1] + all_right[:1], - } - ) diff --git a/drawing_to_fsd_layout/image_processing.py b/drawing_to_fsd_layout/image_processing.py index e77ea2d..eba70c5 100644 --- a/drawing_to_fsd_layout/image_processing.py +++ b/drawing_to_fsd_layout/image_processing.py @@ -205,9 +205,17 @@ def extract_track_edges( if len(best_clusters) == 2: outer, inner = best_clusters cc_outer, cc_inner = best_ccs - else: + elif len(best_clusters) == 4: outer, _, inner, _ = best_clusters cc_outer, _, cc_inner, _ = best_ccs + elif len(best_clusters) == 1: + st.error( + "There was an error extracting the two track edges. Have you drawn a closed track?" + ) + st.stop() + elif len(best_clusters) == 0: + st.error("No track edges were found. Have you drawn a track?") + st.stop() if show_steps: plt.figure() @@ -228,6 +236,7 @@ def extract_track_edges( return outer_ordered, inner_ordered + @st.cache(show_spinner=False) def fix_edges_orientation_and_scale_to_unit( edge_a: FloatArrayNx2, edge_b: FloatArrayNx2 diff --git a/media/after.png b/media/after.png old mode 100644 new mode 100755 index 7a14d05..da439de Binary files a/media/after.png and b/media/after.png differ diff --git a/streamlit_app.py b/streamlit_app.py index bde506c..ab5dcc7 100644 --- a/streamlit_app.py +++ b/streamlit_app.py @@ -1,11 +1,13 @@ from enum import Enum +from pathlib import Path import matplotlib.pyplot as plt import numpy as np import streamlit as st from skimage import io -from drawing_to_fsd_layout.common import FloatArrayNx2 +from drawing_to_fsd_layout.canvas_image import get_canvas_image +from drawing_to_fsd_layout.common import FloatArrayNx2, find_github_link_of_repo from drawing_to_fsd_layout.cone_placement import ( calculate_min_track_width, decide_start_finish_line_position_and_direction, @@ -15,7 +17,6 @@ ) from drawing_to_fsd_layout.export import ( cones_to_lyt, - export_for_chrono_json_str, export_json_string, ) from drawing_to_fsd_layout.image_processing import ( @@ -25,7 +26,8 @@ rotate, ) from drawing_to_fsd_layout.spline_fit import SplineFitterFactory -from drawing_to_fsd_layout.canvas_image import get_canvas_image + +st.set_page_config(page_title="Drawing to Layout", page_icon="🏎️") class UploadType(str, Enum): @@ -34,7 +36,7 @@ class UploadType(str, Enum): class ScalingMethod(str, Enum): - MIN_TRACK_WIDTH = "min_track_width" + MINIMUM_TRACK_WIDTH = "minimum_track_width" CENTERLINE_LENGTH = "centerline_length" @@ -65,12 +67,24 @@ def load_example_image() -> np.ndarray: def image_upload_widget() -> tuple[np.ndarray, bool]: - mode = st.radio("Image upload", ["Upload", "Canvas", "Example Image"], horizontal=True) + mode = st.radio( + "Image upload", + ["Upload", "Canvas", "Example Image"], + horizontal=True, + help="Choose how to upload an image. You can upload an image of a track drawing, use the canvas inside this app, or use an example image to get an understanding of how the app works.", + ) should_show_image = True if mode == "Upload": - upload_type = UploadType[st.radio("Upload type", [x.name for x in UploadType])] + upload_type = UploadType[ + st.radio( + "Upload type", + [x.name for x in UploadType], + horizontal=True, + help="Choose whether to upload an image file or enter a URL to an image.", + ) + ] if upload_type == UploadType.FILE: uploaded_file = st.file_uploader("Upload an image") @@ -94,12 +108,14 @@ def image_upload_widget() -> tuple[np.ndarray, bool]: elif mode == "Example Image": image = load_example_image() - + elif mode == "Canvas": # the canvas already shows the image, so we don't need to show it again should_show_image = False image = get_canvas_image() + # st.image(image) + assert image is not None return image, should_show_image @@ -120,15 +136,27 @@ def plot_contours( def main() -> None: st.title("Drawing to FSD Layout Tool by FaSTTUBe") + # dynmaically create issues link so that if the repo is forked the link is still correct + try: + link = find_github_link_of_repo() + except ValueError: + st.write("could not find link to remote") + link = "https://github.com/papalotis/drawing-to-fsd-layout/" + + if not link.endswith("/"): + link += "/" + + link += "issues" + st.warning( - "This software is provided as is. It has undergone very little testing." + "This tool is provided as is. It has undergone very little testing." " There are many bugs and mostly happy path scenarios are considered." + f" If you find a bug, please report it on the [GitHub repository]({link})." ) st.markdown("## Upload image") image, should_show_image = image_upload_widget() if should_show_image: - st.image(image, caption="Uploaded image") with st.spinner("Preprocessing image"): preprocessed_image = load_image_and_preprocess(image) @@ -144,6 +172,16 @@ def main() -> None: ) st.title("Scale to real-world units") + + st.info( + """Choose how to scale the track to real-world units. You can either specify the minimum track width or the centerline length. The track will be scaled to match the specified value. The scaling is not perfect and the resulting track might not have the exact specified value. You might need to play around with the scaling method and the smoothing to get the desired result. + + +The default minimum track width is set to 3.3m. This is because the official minimum track width is 3.0 meters but that is measured from the inside of both track edges. The tool always considers the center of the cones so 0.15 meters are added to each side to compensate. You can change this value to match the track width you want. The track width is the distance between the two track edges. + """, + # icon="📏", + ) + scaling_method = st.radio( "Method to use to scale to real world units", list(ScalingMethod), @@ -153,11 +191,11 @@ def main() -> None: col_left, col_right = st.columns(2) with col_left: desired_track_width = st.number_input( - "Min track width", - min_value=1.0, + "Minimum track width", + min_value=2.0, max_value=7.0, - value=3.0, - disabled=scaling_method != ScalingMethod.MIN_TRACK_WIDTH, + value=3.3, + disabled=scaling_method != ScalingMethod.MINIMUM_TRACK_WIDTH, ) with col_right: desired_centerline_length = st.number_input( @@ -168,7 +206,7 @@ def main() -> None: disabled=scaling_method != ScalingMethod.CENTERLINE_LENGTH, ) - if scaling_method == ScalingMethod.MIN_TRACK_WIDTH: + if scaling_method == ScalingMethod.MINIMUM_TRACK_WIDTH: unscaled_min_track_width = calculate_min_track_width( contour_a_fixed, contour_b_fixed ) @@ -316,34 +354,49 @@ def main() -> None: head_width=min_track_width, ) - plt.plot(*right_cones.T, ".", c="gold", label="Contour A", markersize=2) - plt.plot(*left_cones.T, ".", c="b", label="Contour B", markersize=2) + plt.plot( + *right_cones[1:].T, + "o", + c="gold", + label="Contour A", + markersize=5, + markeredgecolor="black", + ) + plt.plot( + *left_cones[1:].T, + "o", + c="b", + label="Contour B", + markersize=5, + markeredgecolor="black", + ) + plt.plot( [left_cones[0, 0], right_cones[0, 0]], [left_cones[0, 1], right_cones[0, 1]], - "x", + "o", c="orange", + markersize=7, + markeredgecolor="black", ) plt.axis("equal") plt.title("Final track layout") st.pyplot(plt.gcf()) + st.info( + "The cone markers in the plot are not to scale. They are just for visualization." + ) st.title("Export") track_name = st.text_input("Track name", "Custom Track") track_name_normalized = track_name.replace(" ", "_").lower() - do_chrono = st.experimental_get_query_params().get("chrono", False) - - if not do_chrono: - tab_json, tab_lfs = st.tabs(["JSON", "Live for Speed Layout"]) - tab_chrono = None - else: - tab_json, tab_lfs, tab_chrono = st.tabs( - ["JSON", "Live for Speed Layout", "Chrono"] - ) + tab_json, tab_lfs = st.tabs(["JSON", "Live for Speed Layout"]) with tab_json: + st.info( + "The JSON object has 3 keys: `x`, `y` and `color`. `x` and `y` are lists of floats representing the x and y coordinates of the cones. `color` is a list of strings representing the color of the cones. The colors are either `blue`, `yellow` or `orange_big`. The length of the three lists should be the same. The cones appear in the same order as the track direction. The first cone is the start/finish line. The cones are ordered in the direction of the track." + ) json_string = export_json_string(left_cones, right_cones) st.download_button( "Download JSON", @@ -362,18 +415,9 @@ def main() -> None: lyt_bytes, file_name=filename, mime="application/octet-stream", + help="This file should be placed inside the `data/layout` folder of your LFS installation.", ) - if tab_chrono: - with tab_chrono: - chrono_string = export_for_chrono_json_str(left_cones, right_cones) - st.download_button( - "Download Chrono JSON", - chrono_string, - file_name=f"{track_name_normalized}.json", - mime="application/json", - ) - if __name__ == "__main__": main()