|
| 1 | +"""Convert OGC SLD raster ColorMap files to QGIS QML format. |
| 2 | +
|
| 3 | +Usage: |
| 4 | + python sld_to_qml.py input.sld # writes input.qml next to input.sld |
| 5 | + python sld_to_qml.py input.sld output.qml # explicit output path |
| 6 | + python sld_to_qml.py *.sld # batch convert all SLDs in a folder |
| 7 | +""" |
| 8 | + |
| 9 | +import argparse |
| 10 | +import xml.etree.ElementTree as ET |
| 11 | +from pathlib import Path |
| 12 | + |
| 13 | + |
| 14 | +def parse_sld(sld_path): |
| 15 | + """Extract color map entries from an SLD file.""" |
| 16 | + tree = ET.parse(sld_path) |
| 17 | + root = tree.getroot() |
| 18 | + entries = [] |
| 19 | + for entry in root.iter("{http://www.opengis.net/sld}ColorMapEntry"): |
| 20 | + color = entry.get("color") |
| 21 | + quantity = entry.get("quantity") |
| 22 | + label = entry.get("label", "") |
| 23 | + entries.append((float(quantity), color, label)) |
| 24 | + return entries |
| 25 | + |
| 26 | + |
| 27 | +def hex_to_rgba_str(hex_color): |
| 28 | + """Convert #RRGGBB to QGIS 'R,G,B,255,rgb:r,g,b,1' format.""" |
| 29 | + h = hex_color.lstrip("#") |
| 30 | + r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) |
| 31 | + rf, gf, bf = r / 255.0, g / 255.0, b / 255.0 |
| 32 | + return f"{r},{g},{b},255,rgb:{rf},{gf},{bf},1" |
| 33 | + |
| 34 | + |
| 35 | +def build_gradient_stops(entries): |
| 36 | + """Build QGIS gradient stops string from color entries.""" |
| 37 | + if len(entries) < 3: |
| 38 | + return "" |
| 39 | + min_val = entries[0][0] |
| 40 | + max_val = entries[-1][0] |
| 41 | + val_range = max_val - min_val |
| 42 | + if val_range == 0: |
| 43 | + return "" |
| 44 | + stops = [] |
| 45 | + for val, color, _label in entries[1:-1]: |
| 46 | + pos = (val - min_val) / val_range |
| 47 | + rgba = hex_to_rgba_str(color) |
| 48 | + stops.append(f"{pos:.6f};{rgba};rgb;ccw") |
| 49 | + return ":".join(stops) |
| 50 | + |
| 51 | + |
| 52 | +def generate_qml(entries, output_path): |
| 53 | + """Generate a QGIS QML file from SLD color map entries.""" |
| 54 | + min_val = entries[0][0] |
| 55 | + max_val = entries[-1][0] |
| 56 | + color1_rgba = hex_to_rgba_str(entries[0][1]) |
| 57 | + color2_rgba = hex_to_rgba_str(entries[-1][1]) |
| 58 | + stops_str = build_gradient_stops(entries) |
| 59 | + |
| 60 | + item_lines = [] |
| 61 | + for val, color, label in entries: |
| 62 | + item_lines.append( |
| 63 | + f' <item color="{color}" value="{val}" alpha="255" label="{label}"/>' |
| 64 | + ) |
| 65 | + items_xml = "\n".join(item_lines) |
| 66 | + |
| 67 | + stops_option = "" |
| 68 | + if stops_str: |
| 69 | + stops_option = f'\n <Option name="stops" value="{stops_str}" type="QString"/>' |
| 70 | + |
| 71 | + qml = f'''<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'> |
| 72 | +<qgis version="3.40.5-Bratislava" hasScaleBasedVisibilityFlag="0" styleCategories="AllStyleCategories" autoRefreshMode="Disabled" autoRefreshTime="0" maxScale="0" minScale="1e+08"> |
| 73 | + <flags> |
| 74 | + <Identifiable>1</Identifiable> |
| 75 | + <Removable>1</Removable> |
| 76 | + <Searchable>1</Searchable> |
| 77 | + <Private>0</Private> |
| 78 | + </flags> |
| 79 | + <temporal mode="0" enabled="0" fetchMode="0" bandNumber="1"> |
| 80 | + <fixedRange> |
| 81 | + <start></start> |
| 82 | + <end></end> |
| 83 | + </fixedRange> |
| 84 | + </temporal> |
| 85 | + <elevation mode="RepresentsElevationSurface" enabled="0" zscale="1" band="1" symbology="Line" zoffset="0"> |
| 86 | + <data-defined-properties> |
| 87 | + <Option type="Map"> |
| 88 | + <Option type="QString" value="" name="name"/> |
| 89 | + <Option name="properties"/> |
| 90 | + <Option type="QString" value="collection" name="type"/> |
| 91 | + </Option> |
| 92 | + </data-defined-properties> |
| 93 | + <profileLineSymbol> |
| 94 | + <symbol alpha="1" force_rhr="0" type="line" frame_rate="10" clip_to_extent="1" is_animated="0" name=""> |
| 95 | + <data_defined_properties> |
| 96 | + <Option type="Map"> |
| 97 | + <Option type="QString" value="" name="name"/> |
| 98 | + <Option name="properties"/> |
| 99 | + <Option type="QString" value="collection" name="type"/> |
| 100 | + </Option> |
| 101 | + </data_defined_properties> |
| 102 | + <layer class="SimpleLine" pass="0" id="{{{{auto}}}}" enabled="1" locked="0"> |
| 103 | + <Option type="Map"> |
| 104 | + <Option type="QString" value="0" name="align_dash_pattern"/> |
| 105 | + <Option type="QString" value="square" name="capstyle"/> |
| 106 | + <Option type="QString" value="5;2" name="customdash"/> |
| 107 | + <Option type="QString" value="3x:0,0,0,0,0,0" name="customdash_map_unit_scale"/> |
| 108 | + <Option type="QString" value="MM" name="customdash_unit"/> |
| 109 | + <Option type="QString" value="0" name="dash_pattern_offset"/> |
| 110 | + <Option type="QString" value="3x:0,0,0,0,0,0" name="dash_pattern_offset_map_unit_scale"/> |
| 111 | + <Option type="QString" value="MM" name="dash_pattern_offset_unit"/> |
| 112 | + <Option type="QString" value="0" name="draw_inside_polygon"/> |
| 113 | + <Option type="QString" value="bevel" name="joinstyle"/> |
| 114 | + <Option type="QString" value="141,90,153,255,rgb:0.55294117647058827,0.35294117647058826,0.59999999999999998,1" name="line_color"/> |
| 115 | + <Option type="QString" value="solid" name="line_style"/> |
| 116 | + <Option type="QString" value="0.6" name="line_width"/> |
| 117 | + <Option type="QString" value="MM" name="line_width_unit"/> |
| 118 | + <Option type="QString" value="0" name="offset"/> |
| 119 | + <Option type="QString" value="3x:0,0,0,0,0,0" name="offset_map_unit_scale"/> |
| 120 | + <Option type="QString" value="MM" name="offset_unit"/> |
| 121 | + <Option type="QString" value="0" name="ring_filter"/> |
| 122 | + <Option type="QString" value="0" name="trim_distance_end"/> |
| 123 | + <Option type="QString" value="3x:0,0,0,0,0,0" name="trim_distance_end_map_unit_scale"/> |
| 124 | + <Option type="QString" value="MM" name="trim_distance_end_unit"/> |
| 125 | + <Option type="QString" value="0" name="trim_distance_start"/> |
| 126 | + <Option type="QString" value="3x:0,0,0,0,0,0" name="trim_distance_start_map_unit_scale"/> |
| 127 | + <Option type="QString" value="MM" name="trim_distance_start_unit"/> |
| 128 | + <Option type="QString" value="0" name="tweak_dash_pattern_on_corners"/> |
| 129 | + <Option type="QString" value="0" name="use_custom_dash"/> |
| 130 | + <Option type="QString" value="3x:0,0,0,0,0,0" name="width_map_unit_scale"/> |
| 131 | + </Option> |
| 132 | + <data_defined_properties> |
| 133 | + <Option type="Map"> |
| 134 | + <Option type="QString" value="" name="name"/> |
| 135 | + <Option name="properties"/> |
| 136 | + <Option type="QString" value="collection" name="type"/> |
| 137 | + </Option> |
| 138 | + </data_defined_properties> |
| 139 | + </layer> |
| 140 | + </symbol> |
| 141 | + </profileLineSymbol> |
| 142 | + <profileFillSymbol> |
| 143 | + <symbol alpha="1" force_rhr="0" type="fill" frame_rate="10" clip_to_extent="1" is_animated="0" name=""> |
| 144 | + <data_defined_properties> |
| 145 | + <Option type="Map"> |
| 146 | + <Option type="QString" value="" name="name"/> |
| 147 | + <Option name="properties"/> |
| 148 | + <Option type="QString" value="collection" name="type"/> |
| 149 | + </Option> |
| 150 | + </data_defined_properties> |
| 151 | + <layer class="SimpleFill" pass="0" id="{{{{auto}}}}" enabled="1" locked="0"> |
| 152 | + <Option type="Map"> |
| 153 | + <Option type="QString" value="3x:0,0,0,0,0,0" name="border_width_map_unit_scale"/> |
| 154 | + <Option type="QString" value="141,90,153,255,rgb:0.55294117647058827,0.35294117647058826,0.59999999999999998,1" name="color"/> |
| 155 | + <Option type="QString" value="bevel" name="joinstyle"/> |
| 156 | + <Option type="QString" value="0,0" name="offset"/> |
| 157 | + <Option type="QString" value="3x:0,0,0,0,0,0" name="offset_map_unit_scale"/> |
| 158 | + <Option type="QString" value="MM" name="offset_unit"/> |
| 159 | + <Option type="QString" value="35,35,35,255,rgb:0.13725490196078433,0.13725490196078433,0.13725490196078433,1" name="outline_color"/> |
| 160 | + <Option type="QString" value="no" name="outline_style"/> |
| 161 | + <Option type="QString" value="0.26" name="outline_width"/> |
| 162 | + <Option type="QString" value="MM" name="outline_width_unit"/> |
| 163 | + <Option type="QString" value="solid" name="style"/> |
| 164 | + </Option> |
| 165 | + <data_defined_properties> |
| 166 | + <Option type="Map"> |
| 167 | + <Option type="QString" value="" name="name"/> |
| 168 | + <Option name="properties"/> |
| 169 | + <Option type="QString" value="collection" name="type"/> |
| 170 | + </Option> |
| 171 | + </data_defined_properties> |
| 172 | + </layer> |
| 173 | + </symbol> |
| 174 | + </profileFillSymbol> |
| 175 | + </elevation> |
| 176 | + <customproperties> |
| 177 | + <Option type="Map"> |
| 178 | + <Option type="bool" value="false" name="WMSBackgroundLayer"/> |
| 179 | + <Option type="bool" value="false" name="WMSPublishDataSourceUrl"/> |
| 180 | + <Option type="int" value="0" name="embeddedWidgets/count"/> |
| 181 | + <Option type="QString" value="Value" name="identify/format"/> |
| 182 | + </Option> |
| 183 | + </customproperties> |
| 184 | + <mapTip enabled="1"></mapTip> |
| 185 | + <pipe-data-defined-properties> |
| 186 | + <Option type="Map"> |
| 187 | + <Option type="QString" value="" name="name"/> |
| 188 | + <Option name="properties"/> |
| 189 | + <Option type="QString" value="collection" name="type"/> |
| 190 | + </Option> |
| 191 | + </pipe-data-defined-properties> |
| 192 | + <pipe> |
| 193 | + <provider> |
| 194 | + <resampling zoomedOutResamplingMethod="nearestNeighbour" maxOversampling="2" zoomedInResamplingMethod="nearestNeighbour" enabled="false"/> |
| 195 | + </provider> |
| 196 | + <rasterrenderer classificationMin="{min_val}" classificationMax="{max_val}" nodataColor="" type="singlebandpseudocolor" band="1" alphaBand="-1" opacity="1"> |
| 197 | + <rasterTransparency/> |
| 198 | + <minMaxOrigin> |
| 199 | + <limits>None</limits> |
| 200 | + <extent>WholeRaster</extent> |
| 201 | + <statAccuracy>Estimated</statAccuracy> |
| 202 | + <cumulativeCutLower>0.02</cumulativeCutLower> |
| 203 | + <cumulativeCutUpper>0.98</cumulativeCutUpper> |
| 204 | + <stdDevFactor>2</stdDevFactor> |
| 205 | + </minMaxOrigin> |
| 206 | + <rastershader> |
| 207 | + <colorrampshader clip="0" colorRampType="INTERPOLATED" minimumValue="{min_val}" maximumValue="{max_val}" classificationMode="1" labelPrecision="4"> |
| 208 | + <colorramp type="gradient" name="[source]"> |
| 209 | + <Option type="Map"> |
| 210 | + <Option type="QString" value="{color1_rgba}" name="color1"/> |
| 211 | + <Option type="QString" value="{color2_rgba}" name="color2"/> |
| 212 | + <Option type="QString" value="ccw" name="direction"/> |
| 213 | + <Option type="QString" value="0" name="discrete"/> |
| 214 | + <Option type="QString" value="gradient" name="rampType"/> |
| 215 | + <Option type="QString" value="rgb" name="spec"/>{stops_option} |
| 216 | + </Option> |
| 217 | + </colorramp> |
| 218 | +{items_xml} |
| 219 | + <rampLegendSettings suffix="" direction="0" prefix="" maximumLabel="" useContinuousLegend="1" minimumLabel="" orientation="2"> |
| 220 | + <numericFormat id="basic"> |
| 221 | + <Option type="Map"> |
| 222 | + <Option name="decimal_separator" type="invalid"/> |
| 223 | + <Option name="decimals" value="6" type="int"/> |
| 224 | + <Option name="rounding_type" value="0" type="int"/> |
| 225 | + <Option name="show_plus" value="false" type="bool"/> |
| 226 | + <Option name="show_thousand_separator" value="true" type="bool"/> |
| 227 | + <Option name="show_trailing_zeros" value="false" type="bool"/> |
| 228 | + <Option name="thousand_separator" type="invalid"/> |
| 229 | + </Option> |
| 230 | + </numericFormat> |
| 231 | + </rampLegendSettings> |
| 232 | + </colorrampshader> |
| 233 | + </rastershader> |
| 234 | + </rasterrenderer> |
| 235 | + <brightnesscontrast gamma="1" contrast="0" brightness="0"/> |
| 236 | + <huesaturation colorizeGreen="128" colorizeStrength="100" invertColors="0" saturation="0" colorizeOn="0" colorizeBlue="128" grayscaleMode="0" colorizeRed="255"/> |
| 237 | + <rasterresampler maxOversampling="2"/> |
| 238 | + <resamplingStage>resamplingFilter</resamplingStage> |
| 239 | + </pipe> |
| 240 | + <blendMode>0</blendMode> |
| 241 | +</qgis> |
| 242 | +''' |
| 243 | + Path(output_path).write_text(qml) |
| 244 | + print(f"Created: {output_path} ({len(entries)} color stops)") |
| 245 | + |
| 246 | + |
| 247 | +def main(): |
| 248 | + parser = argparse.ArgumentParser( |
| 249 | + description="Convert OGC SLD raster ColorMap files to QGIS QML format." |
| 250 | + ) |
| 251 | + parser.add_argument("sld_files", nargs="+", type=Path, help="SLD file(s) to convert") |
| 252 | + parser.add_argument( |
| 253 | + "-o", |
| 254 | + "--output", |
| 255 | + type=Path, |
| 256 | + default=None, |
| 257 | + help="Output QML path (only valid with a single input file)", |
| 258 | + ) |
| 259 | + args = parser.parse_args() |
| 260 | + |
| 261 | + if args.output and len(args.sld_files) > 1: |
| 262 | + parser.error("-o/--output can only be used with a single input file") |
| 263 | + |
| 264 | + for sld_path in args.sld_files: |
| 265 | + if not sld_path.exists(): |
| 266 | + print(f"SKIP (not found): {sld_path}") |
| 267 | + continue |
| 268 | + qml_path = args.output if args.output else sld_path.with_suffix(".qml") |
| 269 | + entries = parse_sld(sld_path) |
| 270 | + if not entries: |
| 271 | + print(f"SKIP (no ColorMapEntry found): {sld_path}") |
| 272 | + continue |
| 273 | + generate_qml(entries, qml_path) |
| 274 | + |
| 275 | + |
| 276 | +if __name__ == "__main__": |
| 277 | + main() |
0 commit comments