diff --git a/scripts/r.reclass.area/r.reclass.area.md b/scripts/r.reclass.area/r.reclass.area.md index a20ff64d596..66c586760f2 100644 --- a/scripts/r.reclass.area/r.reclass.area.md +++ b/scripts/r.reclass.area/r.reclass.area.md @@ -1,10 +1,25 @@ ## DESCRIPTION -*r.reclass.area* reclasses a raster map greater or less than a user -specified area size (in hectares). +*r.reclass.area* removes areas (pixel clumps) from a raster map +that are smaller than a user specified **lower** area size and/or +greater than a user specified **upper** theshold (both in hectares). -If the **-c** flag is used, *r.reclass.area* will skip the creation of a -clumped raster and assume that the input raster is already clumped. +*r.reclass.area* provides two **method**s for filtering: + +- *reclass* - raster based filtering +- *rmarea* - vector based filtering + +With the *reclass* method, the input raster map is clumped and +then reclassified using the clump size. With the **-m** flag, +*reclass* method will remove small areas already during clumping, +using the *minsize* option there. If the **-c** flag is used, +*r.reclass.area* will skip the creation of a clumped raster and +assume that the input raster map is already clumped. + +The *rmarea* method converts the raster map to vector and removes +areas outside the given thresholds using [v.clean](v.clean.md) and +[v.edit](v.edit.md). With the **-v** flag the **output** is the +filtered vector map instead of a raster. ## EXAMPLES @@ -19,7 +34,7 @@ r.report zipcodes unit=h Extract only areas greater than 2000 ha, NULL otherwise: ```sh -r.reclass.area input=zipcodes output=zipcodes_larger2000ha mode=greater value=2000 +r.reclass.area input=zipcodes output=zipcodes_larger2000ha greater=2000 r.report zipcodes_larger2000ha unit=h ``` @@ -30,10 +45,10 @@ r.report zipcodes_larger2000ha unit=h In this example, the ZIP code map in the North Carolina sample dataset is filtered for smaller areas which are substituted with the value of the respective adjacent area with largest shared boundary. Reclass by -substitutional removing of areas minor of 1000 ha: +substitutional removing of areas smaller than 1000 ha: ```sh -r.reclass.area input=zipcodes output=zipcodes_minor1000ha mode=lesser value=1000 method=rmarea +r.reclass.area input=zipcodes output=zipcodes_minor1000ha lesser=1000 method=rmarea ``` ![Figure: r.reclass.area method=rmarea](zipcodes_minor1000ha.png) @@ -41,10 +56,12 @@ r.reclass.area input=zipcodes output=zipcodes_minor1000ha mode=lesser value=1000 ## SEE ALSO -*[r.reclass](r.reclass.md), [r.clump](r.clump.md), -[r.stats](r.stats.md)* +*[r.clump](r.clump.md), [r.reclass](r.reclass.md), +[r.stats](r.stats.md), [v.to.rast](v.to.rast.md), +[v.clean](v.clean.md), [v.edit](v.edit.md)* ## AUTHORS NRCS, Markus Neteler +Stefan Blumentath diff --git a/scripts/r.reclass.area/r.reclass.area.py b/scripts/r.reclass.area/r.reclass.area.py index ff43f26e454..4f593d6bc1a 100755 --- a/scripts/r.reclass.area/r.reclass.area.py +++ b/scripts/r.reclass.area/r.reclass.area.py @@ -7,13 +7,14 @@ # Converted to Python by Glynn Clements # Added rmarea method by Luca Delucchi # PURPOSE: Reclasses a raster map greater or less than user specified area size (in hectares) -# COPYRIGHT: (C) 1999,2008,2014 by the GRASS Development Team +# COPYRIGHT: (C) 1999-2026 by the GRASS Development Team # # This program is free software under the GNU General Public # License (>=v2). Read the file COPYING that comes with GRASS # for details. # ############################################################################# +# 04/2026: rewrite with Tools API and new options (Stefan Blumentrath) # 10/2013: added option to use a pre-clumped input map (Eric Goddard) # 8/2012: added fp maps support, cleanup, removed tabs AK # 3/2007: added label support MN @@ -39,24 +40,40 @@ # %option # % key: value # % type: double -# % description: Value option that sets the area size limit (in hectares) -# % required: yes +# % description: Value option that sets the area size limit (in hectares) (deprecated) +# % required: no # % guisection: Area # %end # %option # % key: mode # % type: string -# % description: Lesser or greater than specified value +# % description: Keep only areas lesser or greater than specified value (deprecated) # % options: lesser,greater -# % required: yes +# % required: no +# % guisection: Area +# %end + +# %option +# % key: lower +# % type: double +# % description: Value for the lower area size limit (in hectares). Pixel clumps with areas below this value are removed. +# % required: no +# % guisection: Area +# %end + +# %option +# % key: upper +# % type: double +# % description: Value for the upper area size limit (in hectares). Pixel clumps with areas above this value are removed. +# % required: no # % guisection: Area # %end # %option # % key: method # % type: string -# % description: Method used for reclassification +# % description: Method used for area size filtering # % options: reclass,rmarea # % answer: reclass # % guisection: Area @@ -69,174 +86,300 @@ # %flag # % key: d -# % description: Clumps including diagonal neighbors +# % description: Generate clumps including diagonal neighbors # %end -import sys -import os +# %flag +# % key: i +# % description: Invert filter (remove clumps larger than lower and smaller than upper) +# %end + +# %flag +# % key: m +# % description: Apply lower area threshold as "minsize" during clumping +# %end + +# %flag +# % key: v +# % description: Output vector map, do not convert back to raster (only for method 'rmarea') +# %end + +# %rules +# % required: lower, upper, mode +# % requires: mode, value +# % requires: -m, lower +# % excludes: -c, -d, -m +# %end + + import atexit -import grass.script as gs -from grass.script.utils import decode, encode +import io +import sys -TMPRAST = [] +from functools import partial +from math import ceil, inf, prod +from typing import Literal +import grass.script as gs +from grass.tools import Tools -def reclass(inf, outf, lim, clump, diag, les): - infile = inf - outfile = outf - lesser = les - limit = lim - clumped = clump - diagonal = diag +TEMP_PREFIX = gs.tempname(12) - s = gs.read_command("g.region", flags="p") - s = decode(s) - kv = gs.parse_key_val(s, sep=":") - s = kv["projection"].strip().split() - if s == "0": - gs.fatal(_("xy-locations are not supported")) - gs.fatal(_("Need projected data with grids in meters")) - if not gs.find_file(infile)["name"]: - gs.fatal(_("Raster map <%s> not found") % infile) +def cleanup() -> None: + """Delete all temporary maps.""" + tools = Tools(capture_output=False) + tools.g_remove( + flags="fb", + type="all", + pattern=f"{TEMP_PREFIX}_*", + superquiet=True, + ) + - if clumped and diagonal: - gs.fatal(_("flags c and d are mutually exclusive")) +def check_projection() -> tuple[float, float]: + """Check if current projection is supported. - if clumped: - clumpfile = infile - else: - clumpfile = "%s.clump.%s" % (infile.split("@")[0], outfile) - TMPRAST.append(clumpfile) - - if not gs.overwrite(): - if gs.find_file(clumpfile)["name"]: - gs.fatal(_("Temporary raster map <%s> exists") % clumpfile) - if diagonal: - gs.message( - _("Generating a clumped raster file including diagonal neighbors...") - ) - gs.run_command("r.clump", flags="d", input=infile, output=clumpfile) - else: - gs.message(_("Generating a clumped raster file ...")) - gs.run_command("r.clump", input=infile, output=clumpfile) - - if lesser: - gs.message( + Issues a fatal message if the tool does not reliably + work in the current type of projection (XY, latlon) + """ + tools = Tools(capture_output=True) + proj = tools.g_region(flags="p", format="json").json + if proj["crs"]["type_code"] == "0": + gs.fatal( _( - "Generating a reclass map with area size less than " - "or equal to %f hectares..." - ) - % limit + "xy-locations are not supported\n" + "Need projected data with grids in meters", + ), ) + in_proj = gs.parse_key_val(tools.g_proj(flags="p", format="shell").text) + if in_proj["unit"].lower() == "degree": + gs.fatal(_("Latitude-longitude locations are not supported")) + if in_proj["name"].lower() == "xy_location_unprojected": + gs.fatal(_("xy-locations are not supported")) + return proj["ewres"], proj["nsres"] + + +def get_clumpfile( + input_map: str, + resolution: tuple[float, float], + minsize: float | None = None, + *, + clump_flags: Literal["d"] | None = None, +) -> str: + """Generate a clump-raster map.""" + tools = Tools(capture_output=False) + clump_map = f"{TEMP_PREFIX}_clump" + + if minsize: + minsize = ceil((minsize * 10000.0) / prod(resolution)) + + msg = _("Generating a clumped raster file") + if clump_flags: + msg += _(" including diagonal neighbors") + if minsize: + msg += _(" with minimum %s pixels") % minsize + gs.verbose(msg) + tools.r_clump( + flags=clump_flags, + input=input_map, + minsize=minsize, + output=clump_map, + quiet=True, + ) + return clump_map + + +def reclass( + clump_map: str, + output_map: str | None, + lower: float | None = None, + upper: float | None = None, + input_map: str | None = None, +) -> None: + """Perform raster based filtering.""" + tools = Tools(capture_output=True) + stats_input = clump_map + expected_fields_number = 4 + verbose_message = _("Generating a reclass map with area size") + range_filter_message = "" + + # Define lower threshold + if lower: + range_filter_message += _(" larger than or equal to %f hectares...") % lower else: - gs.message( - _( - "Generating a reclass map with area size greater " - "than or equal to %f hectares..." - ) - % limit - ) + lower = -inf - recfile = outfile + ".recl" - TMPRAST.append(recfile) + # Define upper threshold + if upper: + if range_filter_message: + range_filter_message += " and" + range_filter_message += _(" smaller than or equal to %f hectares...") % upper + else: + upper = inf + gs.verbose(verbose_message + range_filter_message) + recfile = f"{TEMP_PREFIX}_recl" sflags = "aln" - if gs.raster_info(infile)["datatype"] in {"FCELL", "DCELL"}: - sflags += "i" - p1 = gs.pipe_command("r.stats", flags=sflags, input=(clumpfile, infile), sep=";") - p2 = gs.feed_command("r.reclass", input=clumpfile, output=recfile, rules="-") + if input_map: + # For non-null clumps r.stats should produce 5 columns + expected_fields_number = 5 + stats_input = (clump_map, input_map) + if gs.raster_info(input_map)["datatype"] in {"FCELL", "DCELL"}: + sflags += "i" + clump_areas = ( + tools.r_stats( + flags=sflags, + input=stats_input, + sep=";", + quiet=True, + ) + .text.rstrip() + .split("\n") + ) + rules = "" - for line in p1.stdout: - f = decode(line).rstrip(os.linesep).split(";") - if len(f) < 5: + for line in clump_areas: + f = line.rstrip().split(";") + if len(f) < expected_fields_number: continue - hectares = float(f[4]) * 0.0001 - test = hectares <= limit if lesser else hectares >= limit - if test: - rules += "%s = %s %s\n" % (f[0], f[2], f[3]) + hectares = float(f[-1]) * 0.0001 + if lower <= hectares <= upper: + rules += "{} = {} {}\n".format(*[f[i] for i in [0, 2, -2]]) if rules: - p2.stdin.write(encode(rules)) - p1.wait() - p2.stdin.close() - p2.wait() - if p2.returncode != 0: - if lesser: - gs.fatal( - _("No areas of size less than or equal to %f hectares found.") % limit - ) - else: - gs.fatal( - _("No areas of size greater than or equal to %f hectares found.") - % limit - ) - gs.mapcalc("$outfile = $recfile", outfile=outfile, recfile=recfile) - - -def rmarea(infile, outfile, thresh, coef): - # transform user input from hectares to map units (kept this for future) - # thresh = thresh * 10000.0 / (float(coef)**2) - # grass.debug("Threshold: %d, coeff linear: %s, coef squared: %d" % - # (thresh, coef, (float(coef)**2)), 0) - - # transform user input from hectares to meters because currently v.clean - # rmarea accept only meters as threshold - thresh *= 10000.0 - vectfile = "%s_vect_%s" % (infile.split("@")[0], outfile) - TMPRAST.append(vectfile) - gs.run_command("r.to.vect", input=infile, output=vectfile, type="area") - cleanfile = "%s_clean_%s" % (infile.split("@")[0], outfile) - TMPRAST.append(cleanfile) - gs.run_command( - "v.clean", input=vectfile, output=cleanfile, tool="rmarea", threshold=thresh - ) + tools.r_reclass( + input=clump_map, + output=recfile, + rules=io.StringIO(rules), + quiet=True, + ) + else: + fatal_message = _("No areas of size ") + gs.fatal(fatal_message + range_filter_message) + tools.r_mapcalc(expression=f"{output_map} = {recfile}") + + +def rmarea( + input_map: str, + output_map: str, + lower: float | None = None, + upper: float | None = None, + *, + return_vector: bool = False, +) -> None: + """Perform vector based filtering.""" + tools = Tools(capture_output=False) + + if gs.raster_info(input_map)["datatype"] != "CELL": + gs.warning( + _("Raster map <%s> is not of type 'CELL'. Results may not be as expected."), + ) - gs.run_command( - "v.to.rast", input=cleanfile, output=outfile, use="attr", attrcolumn="value" + # Setup temporary map names with pattern + vectfile = f"{TEMP_PREFIX}_vect" + cleanfile = f"{TEMP_PREFIX}_clean" + edit_vector = vectfile + + # Convert input map to vector raster values as category + tools.r_to_vect( + flags="v", + input=input_map, + output=vectfile, + type="area", + quiet=True, ) + # Apply lower threshold + # transform user input from hectares to meters because currently v.clean + # rmarea accept only meters as threshold + if lower: + lower *= 10000.0 + tools.v_clean( + input=vectfile, + output=cleanfile, + tool="rmarea", + threshold=lower, + quiet=True, + ) + edit_vector = cleanfile + + # Apply upper threshold + if upper: + tools.v_to_db( + map=edit_vector, + type="centroid", + option="area", + columns="area_ha", + units="hectares", + quiet=True, + ) + tools.v_edit( + map=edit_vector, + tool="delete", + where=f"area_ha >= {upper}", + superquiet=True, + ) -def main(): - infile = options["input"] - value = options["value"] - mode = options["mode"] - outfile = options["output"] - global method - method = options["method"] - clumped = flags["c"] - diagonal = flags["d"] - - # check for unsupported locations - in_proj = gs.parse_command("g.proj", flags="p", format="shell") - if in_proj["unit"].lower() == "degree": - gs.fatal(_("Latitude-longitude locations are not supported")) - if in_proj["name"].lower() == "xy_location_unprojected": - gs.fatal(_("xy-locations are not supported")) - - # check lesser and greater parameters - limit = float(value) - if mode == "greater" and method == "rmarea": - gs.fatal(_("You have to specify mode='lesser' with method='rmarea'")) + # Produce final output map (raster / vector) + if not return_vector: + tools.v_to_rast( + input=edit_vector, + output=output_map, + use="cat", + quiet=True, + ) + else: + tools.v_clean( + flags="c", + input=edit_vector, + output=output_map, + tool=["rmdangle", "rmbridge"], + threshold=-1, + quiet=True, + ) - if not gs.find_file(infile)["name"]: - gs.fatal(_("Raster map <%s> not found") % infile) - if method == "reclass": - reclass(infile, outfile, limit, clumped, diagonal, mode == "lesser") - elif method == "rmarea": - rmarea(infile, outfile, limit, in_proj["meters"]) +def main() -> None: + """Run area filtering.""" + if not gs.find_file(options["input"])["name"]: + gs.fatal(_("Raster map <%s> not found") % options["input"]) - gs.message(_("Generating output raster map <%s>...") % outfile) + # Check for unsupported locations + resolution = check_projection() + # Give deprecation warnings + if options["mode"]: + gs.warning( + _( + "Options and are deprecated. " + "Use 'lower' and/or 'upper'.", + ), + ) + options["lower" if options["mode"] == "greater" else "upper"] = options["value"] + + # Define filter thresholds + lower = float(options["lower"]) if options["lower"] else None + upper = float(options["upper"]) if options["upper"] else None + + # Compute clump map if needed + clump_map = ( + options["input"] + if flags["c"] or options["method"] == "rmarea" + else get_clumpfile( + options["input"], + resolution=resolution, + minsize=lower if flags["m"] else None, + clump_flags="d" if flags["d"] else None, + ) + ) -def cleanup(): - """!Delete temporary maps""" - TMPRAST.reverse() # reclassed map first - for mapp in TMPRAST: - if method == "rmarea": - gs.run_command("g.remove", flags="f", type="vector", name=mapp, quiet=True) - else: - gs.run_command("g.remove", flags="f", type="raster", name=mapp, quiet=True) + # Filter clumped map + filter_method = ( + partial(reclass, input_map=options["input"]) + if options["method"] == "reclass" + else partial(rmarea, return_vector=flags["v"]) + ) + filter_method(clump_map, options["output"], lower=lower, upper=upper) if __name__ == "__main__": diff --git a/scripts/r.reclass.area/testsuite/test_r_reclass_area.py b/scripts/r.reclass.area/testsuite/test_r_reclass_area.py index 96c922bda05..d15b75c441d 100644 --- a/scripts/r.reclass.area/testsuite/test_r_reclass_area.py +++ b/scripts/r.reclass.area/testsuite/test_r_reclass_area.py @@ -17,6 +17,9 @@ class TestReclassArea(TestCase): input = "geology_30m" output = "reclassarea" value = "20" + upper = "4000" + lower = "20" + map_list = [] @classmethod def setUpClass(cls): @@ -28,13 +31,13 @@ def tearDownClass(cls): cls.del_temp_region() cls.runModule( "g.remove", - type="raster", + type="all", flags="f", - name=(cls.output + "Greater", cls.output + "Lesser"), + name=cls.map_list, ) - def test_reclassaeaGreater(self): - """Testing r.reclass.area with greater""" + def test_reclassGreater(self): + """Testing r.reclass.area with greater (old mode).""" self.assertModule( "r.reclass.area", input=self.input, @@ -43,6 +46,7 @@ def test_reclassaeaGreater(self): mode="greater", method="reclass", ) + self.map_list.append(self.output + "Greater") self.assertRasterMinMax( map=self.output + "Greater", refmin=200, @@ -50,8 +54,25 @@ def test_reclassaeaGreater(self): msg="Range of data: min = 200 max = 1000", ) - def test_reclassareaLesser(self): - """Testing r.reclass.area with lesser""" + def test_reclass_lower(self): + """Testing r.reclass.area with lower.""" + self.assertModule( + "r.reclass.area", + input=self.input, + output=f"{self.output}_lower", + lower=self.lower, + method="reclass", + ) + self.map_list.append(f"{self.output}_lower") + self.assertRasterMinMax( + map=f"{self.output}_lower", + refmin=200, + refmax=1000, + msg="Range of data: min = 200 max = 1000", + ) + + def test_reclassLesser(self): + """Testing r.reclass.area with lesser (old mode).""" self.assertModule( "r.reclass.area", input=self.input, @@ -60,6 +81,7 @@ def test_reclassareaLesser(self): mode="lesser", method="reclass", ) + self.map_list.append(self.output + "Lesser") self.assertRasterMinMax( map=self.output + "Lesser", refmin=900, @@ -67,6 +89,107 @@ def test_reclassareaLesser(self): msg="Range of data: min = 900 max = 1000", ) + def test_reclass_upper(self): + """Testing r.reclass.area with upper.""" + self.assertModule( + "r.reclass.area", + input=self.input, + output=f"{self.output}_upper", + upper=self.upper, + method="reclass", + ) + self.map_list.append(f"{self.output}_upper") + self.assertRasterMinMax( + map=f"{self.output}_upper", + refmin=262, + refmax=1000, + msg="Range of data: min = 262 max = 1000", + ) + + def test_reclass_lower_upper(self): + """Testing r.reclass.area with lower and upper.""" + self.assertModule( + "r.reclass.area", + input=self.input, + output=f"{self.output}_lower_upper", + lower=self.lower, + upper=self.upper, + method="reclass", + ) + self.map_list.append(f"{self.output}_lower_upper") + self.assertRasterMinMax( + map=f"{self.output}_lower_upper", + refmin=200, + refmax=1000, + msg="Range of data: min = 200 max = 1000", + ) + + def test_rmarea_lower(self): + """Testing r.reclass.area with rmarea and lower.""" + self.assertModule( + "r.reclass.area", + input=self.input, + output=f"{self.output}_rmarea_lower", + lower=self.lower, + method="rmarea", + ) + self.map_list.append(f"{self.output}_rmarea_lower") + self.assertRasterMinMax( + map=f"{self.output}_rmarea_lower", + refmin=217, + refmax=946, + msg="Range of data: min = 1 max = 11", + ) + + def test_rmarea_upper(self): + """Testing r.reclass.area with rmarea and upper.""" + self.assertModule( + "r.reclass.area", + input=self.input, + output=f"{self.output}_rmarea_upper", + upper=self.upper, + method="rmarea", + ) + self.map_list.append(f"{self.output}_rmarea_upper") + self.assertRasterMinMax( + map=f"{self.output}_rmarea_upper", + refmin=262, + refmax=948, + msg="Range of data: min = 1 max = 13", + ) + + def test_rmaea_lower_upper_vector(self): + """Testing r.reclass.area with rmarea, lower and upper.""" + self.assertModule( + "r.reclass.area", + flags="vd", + input=self.input, + output=f"{self.output}_rmarea_lower_upper", + lower=self.lower, + upper=self.upper, + method="rmarea", + ) + self.map_list.append(f"{self.output}_rmarea_lower_upper") + + def test_rmaea_lower_upper_vector(self): + """Testing r.reclass.area with rmarea, lower and upper.""" + self.assertModule( + "r.reclass.area", + flags="vd", + input="basin", + output=f"{self.output}_rmarea_lower_upper", + lower=self.lower, + upper=self.upper, + method="rmarea", + ) + self.map_list.append(f"{self.output}_rmarea_lower_upper") + # self.assertRasterMinMax( + # map=f"{self.output}_rmarea_lower_upper", + # refmin=200, + # refmax=1000, + # msg="Range of data: min = 200 max = 1000", + # ) + if __name__ == "__main__": test() diff --git a/scripts/r.reclass.area/testsuite/testrra.py b/scripts/r.reclass.area/testsuite/testrra.py index 5e11bf6aecc..c27e07f11b7 100644 --- a/scripts/r.reclass.area/testsuite/testrra.py +++ b/scripts/r.reclass.area/testsuite/testrra.py @@ -89,9 +89,9 @@ def test_method_rmarea(self): ) self.assertRasterMinMax( map=self.output, - refmin=27603, - refmax=27607, - msg="Output Map in degrees must be between 27603 and 27607", + refmin=27511, + refmax=27610, + msg="Output Map in degrees must be between 27511 and 27610", )