1414"""
1515
1616import io
17- import math
1817from collections .abc import Sequence
1918from dataclasses import dataclass
2019
21- from PIL import Image , ImageDraw
20+ from vacuum_map_parser_base . config . drawable import Drawable
2221from vacuum_map_parser_base .map_data import Area , MapData , Path , Point , Wall
2322
2423from roborock .exceptions import RoborockException
3837 erased_packet ,
3938)
4039from .b01_q10_overlays import ZONE_TYPE_NO_GO , ZONE_TYPE_NO_MOP , Q10Zone
40+ from .map_parser import DEFAULT_DRAWABLES , MapParserConfig , _create_image_generator
4141
4242# Path-units-per-pixel candidates for calibration. A dense ss07 path lands a
4343# best fit of 20.0 around the header origin -- ground-truthed June 2026 on the
4949# reach 20 (it railed at the bound), biasing the fit. A dense cleaning path
5050# selects the best fit within this bracket.
5151_Q10_RESOLUTIONS = [step * 0.5 for step in range (24 , 53 )] # 12.0 .. 26.0
52+ # The header resolution is expressed in centimetres per grid pixel, while
53+ # erase/restriction vectors use 5 mm units: one header unit therefore equals
54+ # two vector units. Trace points use a separate 2.5 mm coordinate scale.
55+ _Q10_VECTOR_UNITS_PER_HEADER_RESOLUTION_UNIT = 2.0
5256# A path needs enough shape to constrain a full (origin + resolution) fit; a few
5357# points cannot.
5458_MIN_CALIBRATION_POINTS = 20
5761# one). See :func:`solve_calibration_with_origin`.
5862_MIN_HEADER_CALIBRATION_POINTS = 4
5963
64+ _Q10_DRAWABLE_TYPES = {
65+ Drawable .CHARGER ,
66+ Drawable .NO_GO_AREAS ,
67+ Drawable .NO_MOPPING_AREAS ,
68+ Drawable .PATH ,
69+ Drawable .VACUUM_POSITION ,
70+ Drawable .VIRTUAL_WALLS ,
71+ }
72+ _Q10_DRAWABLES = [drawable for drawable in DEFAULT_DRAWABLES if drawable in _Q10_DRAWABLE_TYPES ]
73+
6074
6175@dataclass (frozen = True )
6276class Q10MapOverlays :
@@ -75,18 +89,18 @@ def render_q10_map(
7589) -> bytes :
7690 """Compose the latest map, trace and DPS inputs into one PNG image.
7791
78- Calibration is derived from ``packet`` (layers + header calibration) and
79- ``trace`` (path points). Once calibrated, erase zones are blanked out of the
80- raster and trace/overlay data is projected and drawn in pixel space.
81- Without a usable trace only the base raster is rendered. Raises
82- :class:`RoborockException` if map rendering fails.
92+ Separate transforms are derived for 2.5 mm trace points and 5 mm
93+ erase/restriction vectors. Erase zones are blanked out of the raster, while
94+ available trace and DPS overlays are projected and drawn in pixel space.
95+ Raises :class:`RoborockException` if map rendering fails.
8396 """
8497 parser = B01Q10MapParser (config )
85- calibration = solve_q10_calibration (packet , trace )
98+ trace_calibration = solve_q10_calibration (packet , trace )
99+ vector_calibration = _vector_calibration (packet , trace_calibration )
86100
87101 render_packet = packet
88- if calibration is not None :
89- cells = _erased_cells (packet .layers , packet .erase_zones , calibration )
102+ if vector_calibration is not None :
103+ cells = _erased_cells (packet .layers , packet .erase_zones , vector_calibration )
90104 if cells :
91105 # Blank the erase-zone cells before parsing the raster so phantom
92106 # areas disappear (as the app shows).
@@ -97,10 +111,17 @@ def render_q10_map(
97111 raise RoborockException ("Failed to render Q10 map image" )
98112 map_data = parsed .map_data
99113
100- if calibration is not None and trace is not None :
101- _place_trace (map_data , calibration , trace )
102- _place_overlays (map_data , calibration , overlays )
103- return _draw_map_content (parsed .image_content , map_data , config = config )
114+ has_drawables = False
115+ if trace_calibration is not None and trace is not None :
116+ charger_heading = packet .header_calibration .charger_phi if packet .header_calibration is not None else None
117+ _place_trace (map_data , trace_calibration , trace , charger_heading = charger_heading )
118+ has_drawables = True
119+ has_drawables = _place_charger_from_header (map_data , packet ) or has_drawables
120+ if vector_calibration is not None :
121+ _place_overlays (map_data , vector_calibration , overlays )
122+ has_drawables = has_drawables or bool (map_data .no_go_areas or map_data .no_mopping_areas or map_data .walls )
123+ if has_drawables :
124+ return _draw_map_content (map_data , config = config )
104125
105126 return parsed .image_content
106127
@@ -137,6 +158,41 @@ def _calibration_from_header(
137158 return solve_calibration_with_origin (packet .layers , points , origin , resolutions = _Q10_RESOLUTIONS )
138159
139160
161+ def _calibration_from_header_metadata (
162+ packet : Q10MapPacket ,
163+ * ,
164+ y_sign : int = 1 ,
165+ ) -> GridCalibration | None :
166+ """Build the vector transform directly from a usable map header."""
167+ header = packet .header_calibration
168+ if header is None or header .resolution <= 0 or (origin := header .origin_pixels ()) is None :
169+ return None
170+ return GridCalibration (
171+ resolution = header .resolution * _Q10_VECTOR_UNITS_PER_HEADER_RESOLUTION_UNIT ,
172+ origin_x = origin [0 ],
173+ origin_y = origin [1 ],
174+ y_sign = y_sign ,
175+ )
176+
177+
178+ def _vector_calibration (
179+ packet : Q10MapPacket ,
180+ trace_calibration : GridCalibration | None ,
181+ ) -> GridCalibration | None :
182+ """Derive the 5 mm erase/restriction-vector transform."""
183+ y_sign = trace_calibration .y_sign if trace_calibration is not None else 1
184+ if calibration := _calibration_from_header_metadata (packet , y_sign = y_sign ):
185+ return calibration
186+ if trace_calibration is None :
187+ return None
188+ return GridCalibration (
189+ resolution = trace_calibration .resolution / 2 ,
190+ origin_x = trace_calibration .origin_x ,
191+ origin_y = trace_calibration .origin_y ,
192+ y_sign = trace_calibration .y_sign ,
193+ )
194+
195+
140196def _calibration_from_fit (layers : GridLayers , points : list [tuple [float , float ]]) -> GridCalibration | None :
141197 """Full origin + resolution fit; needs a reasonably dense path."""
142198 if len (points ) < _MIN_CALIBRATION_POINTS :
@@ -170,6 +226,8 @@ def _place_trace(
170226 map_data : MapData ,
171227 calibration : GridCalibration ,
172228 trace : Q10TracePacket ,
229+ * ,
230+ charger_heading : int | None = None ,
173231) -> None :
174232 """Project trace path, position, heading and charger into pixel space.
175233
@@ -181,11 +239,27 @@ def _place_trace(
181239 robot_position = trace .robot_position
182240 if robot_position is not None :
183241 px , py = calibration .world_to_pixel (robot_position .x , robot_position .y )
184- # Store the heading in projected image coordinates so drawing does not
185- # need to retain the world-to-pixel calibration .
186- map_data .vacuum_position = Point (px , py , - calibration .y_sign * trace .heading )
242+ # The shared V1 marker expects a map-space heading (+Y is up), not the
243+ # top-down PNG angle previously used by Q10's custom marker .
244+ map_data .vacuum_position = Point (px , py , calibration .y_sign * trace .heading )
187245 if pixels :
188- map_data .charger = pixels [0 ]
246+ map_data .charger = Point (
247+ pixels [0 ].x ,
248+ pixels [0 ].y ,
249+ calibration .y_sign * charger_heading if charger_heading is not None else None ,
250+ )
251+
252+
253+ def _place_charger_from_header (
254+ map_data : MapData ,
255+ packet : Q10MapPacket ,
256+ ) -> bool :
257+ """Place the saved dock using its absolute header pixel coordinates."""
258+ header = packet .header_calibration
259+ if header is None or (position := header .charger_pixels ()) is None :
260+ return False
261+ map_data .charger = Point (* position , header .charger_phi )
262+ return True
189263
190264
191265def _place_overlays (
@@ -217,69 +291,18 @@ def to_area(zone: Q10Zone) -> Area | None:
217291
218292
219293def _draw_map_content (
220- image_content : bytes ,
221294 map_data : MapData ,
222295 * ,
223296 config : B01Q10MapParserConfig ,
224- line_color : tuple [int , int , int , int ] = (235 , 64 , 52 , 255 ),
225- position_color : tuple [int , int , int , int ] = (255 , 211 , 0 , 255 ),
226297) -> bytes :
227- """Draw projected map content onto a base PNG and return a fresh PNG."""
228- scale = config .map_scale
229- base = Image .open (io .BytesIO (image_content )).convert ("RGBA" )
230-
231- def to_image (point : Point ) -> tuple [float , float ]:
232- return (point .x * scale , point .y * scale )
233-
234- draw = ImageDraw .Draw (base , "RGBA" )
235-
236- # Erase zones are applied to the raster itself (cells blanked), so they are
237- # not drawn here -- the base image already reflects them.
238-
239- # No-go (blue) and no-mop (magenta) zones beneath the path.
240- for areas , fill , outline in (
241- (map_data .no_go_areas or [], (0 , 120 , 255 , 70 ), (0 , 80 , 200 , 255 )),
242- (map_data .no_mopping_areas or [], (255 , 0 , 200 , 70 ), (200 , 0 , 160 , 255 )),
243- ):
244- for area in areas :
245- polygon = [
246- (area .x0 * scale , area .y0 * scale ),
247- (area .x1 * scale , area .y1 * scale ),
248- (area .x2 * scale , area .y2 * scale ),
249- (area .x3 * scale , area .y3 * scale ),
250- ]
251- draw .polygon (polygon , fill = fill , outline = outline )
252-
253- # Virtual walls (line segments, not polygons) drawn over the zones.
254- for wall in map_data .walls or []:
255- draw .line (
256- [(wall .x0 * scale , wall .y0 * scale ), (wall .x1 * scale , wall .y1 * scale )],
257- fill = (255 , 64 , 64 , 255 ),
258- width = max (2 , scale ),
259- )
260-
261- for path in map_data .path .path if map_data .path else []:
262- if len (path ) >= 2 :
263- draw .line ([to_image (point ) for point in path ], fill = line_color , width = max (1 , scale // 2 ))
264- if map_data .charger is not None :
265- dx , dy = to_image (map_data .charger )
266- draw .ellipse ([dx - scale , dy - scale , dx + scale , dy + scale ], outline = (40 , 200 , 40 , 255 ), width = 2 )
267- robot_position = map_data .vacuum_position
268- if robot_position is not None :
269- cx , cy = to_image (robot_position )
270- radius = scale
271- draw .ellipse ([cx - radius , cy - radius , cx + radius , cy + radius ], fill = position_color )
272- robot_heading = robot_position .a
273- if robot_heading is not None :
274- # Heading was projected into image coordinates alongside the robot
275- # position, so no calibration state is needed during drawing.
276- angle = math .radians (robot_heading )
277- tick = 4 * radius
278- draw .line (
279- [cx , cy , cx + math .cos (angle ) * tick , cy + math .sin (angle ) * tick ],
280- fill = position_color ,
281- width = max (1 , scale // 2 ),
282- )
298+ """Draw Q10 content with the shared V1 image generator."""
299+ if map_data .image is None :
300+ raise RoborockException ("Failed to render Q10 map image" )
301+ generator = _create_image_generator (
302+ MapParserConfig (map_scale = config .map_scale ),
303+ drawables = _Q10_DRAWABLES ,
304+ )
305+ generator .draw_map (map_data )
283306 buffer = io .BytesIO ()
284- base .save (buffer , format = "PNG" )
307+ map_data . image . data .save (buffer , format = "PNG" )
285308 return buffer .getvalue ()
0 commit comments