From 3aa9b9fa2d2e8ae4dcdd58ff7e805b9cd1086a4e Mon Sep 17 00:00:00 2001 From: Lukas Baecker Date: Thu, 19 Sep 2024 17:14:40 +0200 Subject: [PATCH] field_creation_wizard and field update --- field_friend/automations/field.py | 59 +++- field_friend/automations/field_provider.py | 2 + .../interface/components/field_creator.py | 103 ++---- .../interface/components/field_planner.py | 18 +- .../interface/components/geodata_picker.py | 334 ++++++++++++++++++ .../interface/components/leaflet_map.py | 7 + field_friend/interface/pages/main_page.py | 5 +- 7 files changed, 431 insertions(+), 97 deletions(-) create mode 100644 field_friend/interface/components/geodata_picker.py diff --git a/field_friend/automations/field.py b/field_friend/automations/field.py index da2c4542..b6c234c2 100644 --- a/field_friend/automations/field.py +++ b/field_friend/automations/field.py @@ -1,7 +1,10 @@ +import math from dataclasses import dataclass, field +from uuid import uuid4 import rosys -from shapely.geometry import Polygon +from shapely import offset_curve +from shapely.geometry import LineString, Polygon from field_friend.localization import GeoPoint, GeoPointCollection @@ -22,6 +25,8 @@ def reversed(self): points=list(reversed(self.points)), ) + return self # Add this line to fix the missing return statement error + def line_segment(self) -> rosys.geometry.LineSegment: return rosys.geometry.LineSegment(point1=self.points[0].cartesian(), point2=self.points[-1].cartesian()) @@ -30,15 +35,39 @@ def line_segment(self) -> rosys.geometry.LineSegment: @dataclass(slots=True, kw_only=True) class Field(GeoPointCollection): visualized: bool = False + first_row_start: GeoPoint | None = None + first_row_end: GeoPoint | None = None + row_spacing: float = 0.5 + row_number: int = 10 obstacles: list[FieldObstacle] = field(default_factory=list) - rows: list[Row] = field(default_factory=list) - crop: str | None = None + outline_buffer_width: float = 2 @property - def outline(self) -> list[rosys.geometry.Point]: - return self.cartesian() + def outline(self) -> list[GeoPoint]: + assert self.first_row_start is not None + assert self.first_row_end is not None + ab_line = LineString([self.first_row_start.tuple, self.first_row_end.tuple]) + last_row_linestring = offset_curve(ab_line, self.row_spacing * self.row_number / 100000) + end_row_points: list[GeoPoint] = [] + for point in last_row_linestring.coords: + end_row_points.append(GeoPoint(lat=point[0], long=point[1])) + outline_unbuffered: list[GeoPoint] = [] + for i, point in enumerate(end_row_points): + outline_unbuffered.append(point) + outline_unbuffered.append(self.first_row_end) + outline_unbuffered.append(self.first_row_start) + print(f'outline_unbuffered: {outline_unbuffered}') + outline_polygon = Polygon([(p.lat, p.long) for p in outline_unbuffered]) + bufferd_polygon = outline_polygon.buffer( + self.outline_buffer_width/100000, join_style='mitre', mitre_limit=math.inf) + bufferd_polygon_coords = bufferd_polygon.exterior.coords + outline: list[GeoPoint] = [] + for p in bufferd_polygon_coords: + outline.append(GeoPoint(lat=p[0], long=p[1])) + outline.append(outline[0]) + return outline - @property + @ property def outline_as_tuples(self) -> list[tuple[float, float]]: return [p.tuple for p in self.outline] @@ -46,7 +75,7 @@ def area(self) -> float: outline = self.outline if not outline: return 0.0 - polygon = Polygon([(p.x, p.y) for p in outline]) + polygon = Polygon([(p.lat, p.long) for p in outline]) return polygon.area def worked_area(self, worked_rows: int) -> float: @@ -54,3 +83,19 @@ def worked_area(self, worked_rows: int) -> float: if self.area() > 0: worked_area = worked_rows * self.area() / len(self.rows) return worked_area + + @ property + def rows(self) -> list[Row]: + assert self.first_row_start is not None + assert self.first_row_end is not None + ab_line = LineString([self.first_row_start.tuple, self.first_row_end.tuple]) + rows = [] + for i in range(self.row_number+1): + offset = i * self.row_spacing + offset_row_coordinated = offset_curve(ab_line, offset/100_000).coords + row_points: list[GeoPoint] = [] + for point in offset_row_coordinated: + row_points.append(GeoPoint(lat=point[0], long=point[1])) + row = Row(id=str(uuid4()), name=f'{i + 1}', points=row_points) + rows.append(row) + return rows diff --git a/field_friend/automations/field_provider.py b/field_friend/automations/field_provider.py index 33fcd2b3..b2e4ba66 100644 --- a/field_friend/automations/field_provider.py +++ b/field_friend/automations/field_provider.py @@ -37,6 +37,7 @@ def backup(self) -> dict: } def restore(self, data: dict[str, Any]) -> None: + print('tes') fields_data = data.get('fields', []) rosys.persistence.replace_list(self.fields, Field, fields_data) @@ -49,6 +50,7 @@ def restore(self, data: dict[str, Any]) -> None: for j, row in enumerate(rows): for point in row.get('points_wgs84', []): f.rows[j].points.append(GeoPoint(lat=point[0], long=point[1])) + print(f'fields 🛑: {self.fields[0].rows[10]}') def invalidate(self) -> None: self.request_backup() diff --git a/field_friend/interface/components/field_creator.py b/field_friend/interface/components/field_creator.py index 1b2ec413..1bb74f37 100644 --- a/field_friend/interface/components/field_creator.py +++ b/field_friend/interface/components/field_creator.py @@ -4,7 +4,7 @@ import rosys from nicegui import ui -from field_friend.automations import Field, Row +from field_friend.automations import Field from field_friend.automations.navigation import StraightLineNavigation from field_friend.interface.components.monitoring import CameraPosition from field_friend.localization import GeoPoint @@ -29,14 +29,10 @@ def __init__(self, system: 'System'): self.plant_locator = system.plant_locator self.gnss = system.gnss self.field_provider = system.field_provider - - self.field = Field(id=str(uuid4()), name=f'Field #{len(self.field_provider.fields) + 1}') self.first_row_start: GeoPoint | None = None self.first_row_end: GeoPoint | None = None - self.last_row_end: GeoPoint | None = None self.row_spacing = 0.5 - self.padding = 1 - self.padding_bottom = 2 + self.row_number = 10 self.next: Callable = self.find_first_row with ui.dialog() as self.dialog, ui.card().style('width: 900px; max-width: none'): @@ -63,26 +59,25 @@ def find_first_row(self) -> None: 'right before the first crop. ' 'The blue line should be in the center of the row.') \ .classes('text-lg text-center') + ui.label('Place the back center of the robot over the start point of the row.') \ + .classes('text-lg text-center') self.next = self.get_infos def get_infos(self) -> None: + self.headline.text = 'Field Parameters' assert self.gnss.current is not None if not ("R" in self.gnss.current.mode or self.gnss.current.mode == "SSSS"): with self.content: ui.label('No RTK fix available.').classes('text-red') self.first_row_start = self.gnss.current.location - self.row_sight.content = '' - crops = self.plant_locator.crop_category_names[:] - crops.remove('coin_with_hole') self.content.clear() with self.content: - ui.select(label='Cultivated Crop', options=crops, clearable=True).classes('w-40') \ - .bind_value(self.field, 'crop') - ui.number('Crop Spacing', suffix='cm', - value=20, step=1, min=1, max=60) \ + ui.number('Number of rows', + value=10, step=1, min=1, max=500) \ .props('dense outlined').classes('w-40') \ - .tooltip('Set the distance between the crops') + .tooltip('Set the number of rows.')\ + .bind_value(self, 'row_number') ui.number('Row Spacing', suffix='cm', value=50, step=5, min=20, max=100) \ .props('dense outlined').classes('w-40') \ @@ -93,90 +88,38 @@ def get_infos(self) -> None: def find_row_ending(self) -> None: self.headline.text = 'Find Row Ending' self.content.clear() - with self.content: - with ui.row().classes('items-center'): - rosys.automation.automation_controls(self.automator) - ui.label('Press "Play" to start driving forward. ' - 'At the end of the row, press the "Stop" button.') \ - .classes('text-lg text-center') - self.next = self.drive_to_last_row - - def drive_to_last_row(self) -> None: - assert self.gnss.current is not None - if not("R" in self.gnss.current.mode or self.gnss.current.mode == "SSSS"): - with self.content: - ui.label('No RTK fix available.').classes('text-red') - self.first_row_end = self.gnss.current.location - - self.headline.text = 'Drive to Last Row' - self.content.clear() with self.content: rosys.driving.joystick(self.steerer, size=50, color='#6E93D6') - ui.label('Drive the robot to the last row on this side, ' - 'right before the first crop.') \ + ui.label('Drive the robot to the end of the current row.') \ + .classes('text-lg text-center') + ui.label('Place the back center of the robot over the end point of the row.') \ .classes('text-lg text-center') self.next = self.confirm_geometry def confirm_geometry(self) -> None: assert self.gnss.current is not None - if not("R" in self.gnss.current.mode or self.gnss.current.mode == "SSSS"): + if not ("R" in self.gnss.current.mode or self.gnss.current.mode == "SSSS"): with self.content: ui.label('No RTK fix available.').classes('text-red') - self.last_row_end = self.gnss.current.location - + self.first_row_end = self.gnss.current.location assert self.first_row_end is not None - assert self.last_row_end is not None - if not self.build_geometry(): - d = self.first_row_end.distance(self.last_row_end) - ui.label(f'The distance between first row and last row is {d:.2f} m. ' - f'Which does not match well with the provided row spacing of {self.row_spacing} cm.') \ - .classes('text-red') - + # TODO we do not need a last row + # we now only work with first row, row_spacing and numbers of rows self.headline.text = 'Confirm Geometry' self.content.clear() with self.content: with ui.row().classes('items-center'): - rosys.automation.automation_controls(self.automator) - ui.label('Press "Play" to start driving forward. ' - 'At the end of the row, press the "Stop" button.') \ - .classes('w-64 text-lg') + ui.label(f'First Row Start: {self.first_row_start}').classes('text-lg') + ui.label(f'First Row End: {self.first_row_end}').classes('text-lg') + ui.label(f'Row Spacing: {self.row_spacing} m').classes('text-lg') + ui.label(f'Number of Rows: {self.row_number}').classes('text-lg') + ui.button('Cancel', on_click=self.dialog.close).props('color=red') self.next = self._apply - def build_geometry(self) -> bool: - """Build the geometry of the field based on the given points. - - Returns True if the row spacing matches the distance between the first and last row, False otherwise. - Will create rows in any case to make testing easier. - """ - assert self.first_row_start is not None - assert self.first_row_end is not None - assert self.last_row_end is not None - distance = self.first_row_end.distance(self.last_row_end) - number_of_rows = distance / (self.row_spacing) + 1 - # get AB line - a = self.first_row_start.cartesian() - b = self.first_row_end.cartesian() - c = self.last_row_end.cartesian() - ab = a.direction(b) - bc = b.direction(c) - d = a.polar(distance, bc) - for i in range(int(number_of_rows)): - start = a.polar(i * self.row_spacing, bc) - end = b.polar(i * self.row_spacing, bc) - self.field.rows.append(Row(id=str(uuid4()), name=f'Row #{len(self.field.rows)}', - points=[self.first_row_start.shifted(start), - self.first_row_start.shifted(end)] - )) - bottom_left = a.polar(-self.padding_bottom, ab).polar(-self.padding, bc) - top_left = b.polar(self.padding, ab).polar(-self.padding, bc) - top_right = c.polar(self.padding, ab).polar(self.padding, bc) - bottom_right = d.polar(-self.padding_bottom, ab).polar(self.padding, bc) - self.field.points = [self.first_row_start.shifted(p) for p in [bottom_left, top_left, top_right, bottom_right]] - return 1 - number_of_rows % 1 < 0.1 - def _apply(self) -> None: self.dialog.close() - self.field_provider.fields.append(self.field) + self.field_provider.fields.append(Field(id=str(uuid4()), name=f'field_{len(self.field_provider.fields) + 1}', first_row_start=self.first_row_start, + first_row_end=self.first_row_end, row_spacing=self.row_spacing, row_number=self.row_number)) self.field_provider.request_backup() self.field_provider.FIELDS_CHANGED.emit() diff --git a/field_friend/interface/components/field_planner.py b/field_friend/interface/components/field_planner.py index 822974ef..c4c4299b 100644 --- a/field_friend/interface/components/field_planner.py +++ b/field_friend/interface/components/field_planner.py @@ -12,7 +12,7 @@ from field_friend.system import System -TabType = Literal["Plants", "Obstacles", "Outline", "Rows"] +TabType = Literal["Obstacles", "Outline", "Rows"] class ActiveObject(TypedDict): @@ -27,9 +27,9 @@ def __init__(self, system: 'System', leaflet: leaflet_map) -> None: self.field_provider = system.field_provider self.odometer = system.odometer self.gnss = system.gnss - self.cultivatable_crops = system.crop_category_names + # self.cultivatable_crops = system.crop_category_names self.leaflet_map = leaflet - self.tab: TabType = "Plants" + self.tab: TabType = "Outline" self.active_object: ActiveObject | None = None self.active_field: Field | None = None self.restore_actives() @@ -134,16 +134,16 @@ def show_field_settings(self) -> None: .classes("ml-auto").style("display: block; margin-top:auto; margin-bottom: auto;") \ .tooltip("Delete field") with ui.tabs().style("width: 100%;") as self.tabs: - ui.tab("Plants", "Plants") + # ui.tab("Plants", "Plants") ui.tab("Outline", "Outline") ui.tab("Obstacles", "Obstacles") ui.tab("Rows", "Rows") with ui.tab_panels(self.tabs, value=f"{self.tab}", on_change=self.set_tab).style("width: 100%;") as self.panels: - with ui.tab_panel("Plants").style("width: 100%;"): - ui.select(self.cultivatable_crops, label="Cultivated Crop", on_change=self.field_provider.request_backup) \ - .classes("w-40").props('clearable') \ - .bind_value(self.active_field, "crop") \ - .tooltip('Set the cultivated crop which should be kept safe') + # with ui.tab_panel("Plants").style("width: 100%;"): + # ui.select(self.cultivatable_crops, label="Cultivated Crop", on_change=self.field_provider.request_backup) \ + # .classes("w-40").props('clearable') \ + # .bind_value(self.active_field, "crop") \ + # .tooltip('Set the cultivated crop which should be kept safe') with ui.tab_panel("Outline").style("width: 100%;"): for geo_point in self.active_field.points: with ui.row().style("width: 100%;"): diff --git a/field_friend/interface/components/geodata_picker.py b/field_friend/interface/components/geodata_picker.py new file mode 100644 index 00000000..a9dce750 --- /dev/null +++ b/field_friend/interface/components/geodata_picker.py @@ -0,0 +1,334 @@ +import json +import math +import uuid +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Optional + +import fiona +import geopandas as gpd +from shapely import difference, offset_curve +from shapely.geometry import (GeometryCollection, LinearRing, LineString, MultiLineString, MultiPoint, Polygon, + mapping) +from shapely.ops import transform + +import rosys +from nicegui import events, ui + +from ...automations import Field, FieldObstacle, FieldProvider, Row +from ...navigation.point_transformation import cartesian_to_wgs84 + +# Enable fiona driver +fiona.drvsupport.supported_drivers['kml'] = 'rw' # enable KML support which is disabled by default +fiona.drvsupport.supported_drivers['KML'] = 'rw' +fiona.drvsupport.supported_drivers['LIBKML'] = 'rw' + + +class geodata_picker(ui.dialog): + def __init__(self, field_provider: FieldProvider) -> None: + super().__init__() + self.field_provider = field_provider + self.is_farmdroid = False + self.safety_distance = 2.7 + self.working_width = 2.7 + self.headland_tracks = 1 + self.ab_line = 1 + + with self, ui.card(): + with ui.row(): + ui.label("Upload a file.").classes('text-xl w-80') + with ui.row(): + ui.label( + "Only a single polygon will be processed. Supported file formates: .xml with ISO 11783, .shp, .kml.").classes('w-80') + with ui.row(): + ui.label( + "If you want to upload a shape, create a zip-file containing all files (minimum: .shp, .shx, .dbf) and upload the zip.").classes('w-80') + with ui.row(): + ui.upload(on_upload=self.restore_from_file, multiple=False) + with ui.row(): + farmdroid_checkbox = ui.checkbox('FarmDroid Data', on_change=lambda e: self.set_farmdroid(e.value)) + with ui.row().bind_visibility_from(farmdroid_checkbox, 'value').classes('w-80'): + with ui.row().classes('w-full place-items-center'): + with ui.icon('help').classes('text-xl'): + ui.tooltip('The width of the attached tool.') + ui.number(label='Working Width (in m)', value=self.working_width, format='%.2f', + on_change=lambda e: self.set_working_width(e.value)) + with ui.row().classes('w-full place-items-center'): + with ui.icon('help').classes('text-xl'): + ui.tooltip('The distance between the border of the field and the headland.') + ui.number(label='Safety Distance (in m)', value=self.safety_distance, format='%.2f', + on_change=lambda e: self.set_safety_distance(e.value)) + with ui.row().classes('w-full place-items-center'): + with ui.icon('help').classes('text-xl'): + ui.tooltip('Number of headland tracks.') + ui.number(label='headland Tracks', value=self.headland_tracks, format='%.2f', + on_change=lambda e: self.set_headland_tracks(e.value)) + with ui.row().classes('w-full place-items-center'): + with ui.icon('help').classes('text-xl'): + ui.tooltip('In which direction does the AB-line run?') + switch = ui.switch('AB-Line Direction', on_change=lambda e: self.set_ab_line(e.value)) + # TODO EINGABE FÜR DAS OFFSET DER FARMDROID DATEN + + with ui.row().classes('w-full justify-end'): + ui.button('Cancel', on_click=self.close).props('outline') + + def set_farmdroid(self, value) -> None: + self.is_farmdroid = value + + def set_ab_line(self, value) -> None: + if value: + self.ab_line = 2 + else: + self.ab_line = 1 + + def set_safety_distance(self, value) -> None: + self.safety_distance = value + + def set_working_width(self, value) -> None: + self.working_width = value + + def set_headland_tracks(self, value) -> None: + self.headland_tracks = value + + def extract_coordinates_kml(self, event: events.UploadEventArguments) -> list: + coordinates = [] + gdf = gpd.read_file(event.content, drivr="KML") + x_coordinate, y_coordinate = gdf['geometry'].iloc[0].xy + extracted_points = list(zip(x_coordinate, y_coordinate)) + for point in extracted_points: + coordinates.append([point[1], point[0]]) + return [coordinates] + + def extract_coordinates_xml(self, event: events.UploadEventArguments) -> list: + coordinates = [] + tree = ET.parse(event.content) + root = tree.getroot() + for geo_data in root.findall('.//LSG'): + for point in geo_data.findall('.//PNT'): + lat = float(point.attrib['C']) + lon = float(point.attrib['D']) + coordinates.append([lat, lon]) + return [coordinates] + + def extract_coordinates_shp(self, event: events.UploadEventArguments) -> Optional[list]: + coordinates = [] + try: + gdf = gpd.read_file(event.content) + gdf['geometry'] = gdf['geometry'].apply(lambda geom: transform(self.swap_coordinates, geom)) + feature = json.loads(gdf.to_json()) + coordinates = feature["features"][0]["geometry"]["coordinates"] + return coordinates + except: + rosys.notify("The .zip file does not contain a shape file.", type='warning') + return None + + def swap_coordinates(self, lon, lat): + return lat, lon + + def get_extrapoled_line(self, p1, p2) -> LineString: + extrapol_ratio = 10 + a = [p1[0]+extrapol_ratio*(p1[0]-p2[0]), p1[1]+extrapol_ratio*(p1[1]-p2[1])] + b = [p2[0]+extrapol_ratio*(p2[0]-p1[0]), p2[1]+extrapol_ratio*(p2[1]-p1[1])] + return LineString([a, b]) + + def get_rows(self, field: Field) -> list: + if not self.is_farmdroid: + return [] + if self.safety_distance + (self.working_width / 2) < 2.7: + rosys.notify( + 'The distance between the outer headland track and the field boundary is too small. Please check the specified values.') + return [] + lines = [] + outline = [] + for point in field.outline: + outline.append([point.x, point.y]) + outline_polygon = Polygon(outline) + # TODO: hinzufügen von abstand zwischen reihen und arbeitsbreite + buffer_width = self.safety_distance + (self.headland_tracks * self.working_width) + row_spacing = self.working_width / 6 + working_area_meter = outline_polygon.buffer(-buffer_width, join_style='mitre', mitre_limit=math.inf) + working_area_coordinates_meter = mapping(working_area_meter)['coordinates'][0] + working_area_coordinates = [] + for point in working_area_coordinates_meter: + transformed_point = cartesian_to_wgs84([field.reference_lat, field.reference_lon], [-point[1], point[0]]) + working_area_coordinates.append([transformed_point[0], transformed_point[1]]) + if self.ab_line == 1: + ab_line = LineString([(working_area_coordinates[0][0], working_area_coordinates[0][1]), + (working_area_coordinates[1][0], working_area_coordinates[1][1])]) + ab_line_meter = LineString([(working_area_coordinates_meter[0][0], working_area_coordinates_meter[0][1]), + (working_area_coordinates_meter[1][0], working_area_coordinates_meter[1][1])]) + if self.ab_line == 2: + if working_area_coordinates[0][0] == working_area_coordinates[-1][0] and working_area_coordinates[0][1] == working_area_coordinates[-1][1]: + ab_line = LineString([(working_area_coordinates[0][0], working_area_coordinates[0][1]), + (working_area_coordinates[-2][0], working_area_coordinates[-2][1])]) + ab_line_meter = LineString([(working_area_coordinates_meter[0][0], working_area_coordinates_meter[0][1]), + (working_area_coordinates_meter[-2][0], working_area_coordinates_meter[-2][1])]) + else: + ab_line = LineString([(working_area_coordinates[0][0], working_area_coordinates[0][1]), + (working_area_coordinates[-1][0], working_area_coordinates[-1][1])]) + ab_line_meter = LineString([(working_area_coordinates_meter[0][0], working_area_coordinates_meter[0][1]), + (working_area_coordinates_meter[-1][0], working_area_coordinates_meter[-1][1])]) + + direction_check = offset_curve(ab_line_meter, -row_spacing) + if working_area_meter.contains(direction_check) or working_area_meter.intersects(direction_check): + row_spacing = -row_spacing + lines_meter = [] + # lines_meter.append(ab_line_meter) + # lines.append(ab_line) + line_inside = True + while line_inside: + if len(lines_meter) == 0: + line_meter = offset_curve(ab_line_meter, (row_spacing/2)) + else: + line_meter = offset_curve(lines_meter[-1], row_spacing) + if working_area_meter.contains(line_meter) or working_area_meter.intersects(line_meter): + lines_meter.append(line_meter) + line = [] + for point in mapping(line_meter)["coordinates"]: + point_wgs84 = cartesian_to_wgs84([field.reference_lat, field.reference_lon], [-point[1], point[0]]) + line.append(point_wgs84) + linestring = LineString([(line[0][0], line[0][1]), (line[1][0], line[1][1])]) + lines.append(linestring) + else: + line_inside = False + working_area_ring = LinearRing(working_area_coordinates) + rows = [] + row_number = 1 + for line in lines: + point_list = list(mapping(line)['coordinates']) + for index, point in enumerate(point_list): + point_list[index] = list(point) + extrapolated_line = self.get_extrapoled_line(point_list[0], point_list[1]) + intersection_geometry = working_area_ring.intersection(extrapolated_line) + if isinstance(intersection_geometry, MultiPoint): + intersection_list = [] + # TODO hier noch Fälle beachten, bei denen die Linie den Umring in mehr als zwei getrennten Punkten/Segmenten schneidet + for point in list(mapping(intersection_geometry)['coordinates']): + intersection_list.append([point[0], point[1]]) + divided_lines = [] + for obstacle in field.obstacles: + # TODO hier Obstacles buffern und dann diese als Beschnitt nutzen + obstacle_meter = obstacle.points([field.reference_lat, field.reference_lon]) + obstacle_points_meter = [] + for point in obstacle_meter: + obstacle_points_meter.append([point.x, point.y]) + obstacle_polygon = Polygon(obstacle_points_meter) + obstacle_buffer_width = self.working_width + self.safety_distance + obstacle_buffer = obstacle_polygon.buffer( + obstacle_buffer_width, join_style='mitre', mitre_limit=math.inf) + obstacle_coordinates_meter = mapping(obstacle_buffer)['coordinates'][0] + obstacle_coordinates = [] + for point in obstacle_coordinates_meter: + transformed_point = cartesian_to_wgs84( + [field.reference_lat, field.reference_lon], [-point[1], point[0]]) + obstacle_coordinates.append([transformed_point[0], transformed_point[1]]) + divided_lines.append(difference(LineString(intersection_list), Polygon(obstacle_coordinates))) + if any(isinstance(x, MultiLineString) for x in divided_lines): + for segment in divided_lines: + if isinstance(segment, MultiLineString): + row_segment_number = 1 + for line in mapping(segment)['coordinates']: + row_coordinates = [] + for point in line: + row_coordinates.append([point[0], point[1]]) + row_id = str(uuid.uuid4()) + new_row = Row(id=f'{row_id}', name=f'{row_number}_{row_segment_number}', + points_wgs84=row_coordinates) + rows.append(new_row) + row_segment_number += 1 + else: + row_id = str(uuid.uuid4()) + new_row = Row(id=f'{row_id}', name=f'{row_number}', points_wgs84=intersection_list) + rows.append(new_row) + elif isinstance(intersection_geometry, GeometryCollection): + for geometry in mapping(intersection_geometry)['geometries']: + if geometry == LineString or geometry == MultiPoint: + intersection_list = [] + # TODO hier noch Fälle beachten, bei denen die Linie den Umring in mehr als zwei getrennten Punkten/Segmenten schneidet + for point in list(mapping(geometry)['coordinates']): + intersection_list.append([point[0], point[1]]) + divided_lines = [] + for obstacle in field.obstacles: + # TODO hier Obstacles buffern und dann diese als Beschnitt nutzen + obstacle_meter = obstacle.points([field.reference_lat, field.reference_lon]) + obstacle_points_meter = [] + for point in obstacle_meter: + obstacle_points_meter.append([point.x, point.y]) + obstacle_polygon = Polygon(obstacle_points_meter) + obstacle_buffer_width = self.working_width + self.safety_distance + obstacle_buffer = obstacle_polygon.buffer( + obstacle_buffer_width, join_style='mitre', mitre_limit=math.inf) + obstacle_coordinates_meter = mapping(obstacle_buffer)['coordinates'][0] + obstacle_coordinates = [] + for point in obstacle_coordinates_meter: + transformed_point = cartesian_to_wgs84( + [field.reference_lat, field.reference_lon], [-point[1], point[0]]) + obstacle_coordinates.append([transformed_point[0], transformed_point[1]]) + divided_lines.append(difference(LineString(intersection_list), + Polygon(obstacle_coordinates))) + if any(isinstance(x, MultiLineString) for x in divided_lines): + for segment in divided_lines: + if isinstance(segment, MultiLineString): + row_segment_number = 1 + for line in mapping(segment)['coordinates']: + row_coordinates = [] + for point in line: + row_coordinates.append([point[0], point[1]]) + row_id = str(uuid.uuid4()) + new_row = Row( + id=f'{row_id}', name=f'{row_id}_{row_segment_number}', points_wgs84=row_coordinates) + rows.append(new_row) + row_segment_number += 1 + else: + row_id = str(uuid.uuid4()) + new_row = Row(id=f'{row_id}', name=f'{row_number}', points_wgs84=intersection_list) + rows.append(new_row) + # elif type(geometry) == Point: + # intersection_list.append([geometry[0], geometry[1]]) + row_number += 1 + return rows + + def get_obstacles(self, polygon_list): + obstacle_list = [] + for polygon in polygon_list[1:]: + row_id = str(uuid.uuid4()) + new_obstacle = FieldObstacle(id=f'{row_id}', name=f'{row_id}', points_wgs84=polygon) + obstacle_list.append(new_obstacle) + return obstacle_list + + async def restore_from_file(self, e: events.UploadEventArguments) -> None: + self.close() + coordinates: list = [] + if e is None or e.content is None: + rosys.notify("You can only upload the following file formates: .kml ,.xml. with ISO and shape files.", type='warning') + return + elif e.name[-3:].casefold() == "zip": + coordinates = self.extract_coordinates_shp(e) + elif e.name[-3:].casefold() == "kml": + coordinates = self.extract_coordinates_kml(e) + elif e.name[-3:].casefold() == "xml": + coordinates = self.extract_coordinates_xml(e) + else: + rosys.notify("You can only upload the following file formates: .kml ,.xml. with ISO and shape files.", type='warning') + return + if coordinates is None: + rosys.notify("An error occurred while importing the file.", type='negative') + return + reference_point = coordinates[0][0] + field_id = str(uuid.uuid4()) + field = Field(id=f'{field_id}', name=f'{field_id}', outline_wgs84=coordinates[0], + reference_lat=reference_point[0], reference_lon=reference_point[1]) + obstacles = [] + if len(coordinates) > 1: + obstacles = self.get_obstacles(coordinates) + field.obstacles = obstacles + rows = self.get_rows(field) + field.rows = rows + outline = [] + for point in field.outline: + outline.append([point.x, point.y]) + field.area = Polygon(outline).area + if len(coordinates[0]) > 2 and coordinates[0][0] == coordinates[0][-1]: + coordinates[0].pop() # the last point is the same as the first point + self.field_provider.add_field(field) + return diff --git a/field_friend/interface/components/leaflet_map.py b/field_friend/interface/components/leaflet_map.py index 0fcb8a38..6e069c16 100644 --- a/field_friend/interface/components/leaflet_map.py +++ b/field_friend/interface/components/leaflet_map.py @@ -141,6 +141,13 @@ def update_layers(self) -> None: for layer in self.row_layers: self.m.remove_layer(layer) self.row_layers = [] + if len(self.field_provider.fields) > 0: + print(self.field_provider.fields[0].outline_as_tuples) + for row in self.field_provider.fields[0].rows: + self.m.generic_layer(name="polyline", args=[ + row.points_as_tuples, {'color': '#6E93D6'}]) + self.m.generic_layer(name="polygon", args=[ + self.field_provider.fields[0].outline_as_tuples, {'color': '#6E93D6'}]) if current_field is None: return for obstacle in current_field.obstacles: diff --git a/field_friend/interface/pages/main_page.py b/field_friend/interface/pages/main_page.py index b1a6d102..616f1b99 100644 --- a/field_friend/interface/pages/main_page.py +++ b/field_friend/interface/pages/main_page.py @@ -6,6 +6,7 @@ from field_friend.system import System from ..components import camera_card, leaflet_map, operation, robot_scene +from ..components.field_creator import FieldCreator class main_page(): @@ -35,7 +36,6 @@ def content(self, devmode) -> None: operation(self.system) with ui.column().classes('h-full').style('width: calc(45% - 2rem); flex-wrap: nowrap;'): camera_card(self.system) - robot_scene(self.system, self.system.field_navigation.field) with ui.row().style("margin: 1rem; width: calc(100% - 2rem);"): with ui.column(): ui.button('emergency stop', on_click=lambda: self.system.field_friend.estop.set_soft_estop(True)).props('color=red') \ @@ -46,3 +46,6 @@ def content(self, devmode) -> None: ui.space() with ui.row(): rosys.automation.automation_controls(self.system.automator) + with ui.row(): + ui.button("Field Creation Wizard", on_click=lambda: FieldCreator(self.system)).tooltip("Build a field with AB-line in a few simple steps") \ + .classes("ml-auto").style("display: block; margin-top:auto; margin-bottom: auto;")