diff --git a/notebooks/track-floes/track-floes.ipynb b/notebooks/track-floes/track-floes.ipynb index a8c775c8..f47c4cc4 100644 --- a/notebooks/track-floes/track-floes.ipynb +++ b/notebooks/track-floes/track-floes.ipynb @@ -27,9 +27,7 @@ "metadata": {}, "outputs": [], "source": [ - "using IceFloeTracker: pairfloes, deserialize, PaddedView, float64, mosaicview, Gray\n", - "using DataFrames\n", - "imshow(x) = Gray.(x);\n" + "using IceFloeTracker: deserialize, addfloemasks!, adduuid!, add_passtimes!, addψs!, long_tracker, addlatlon!, imshow" ] }, { @@ -80,26 +78,133 @@ "metadata": {}, "outputs": [], "source": [ - "# Load data\n", - "pth = joinpath(HOME, \"test\", \"test_inputs\", \"tracker\")\n", - "floedata = deserialize(joinpath(pth,\"tracker_test_data.dat\"))\n", - "passtimes = deserialize(joinpath(pth,\"passtimes.dat\"))\n", + "begin # Load data\n", + " pth = joinpath(HOME, \"test\", \"test_inputs\", \"tracker\")\n", + " floedata = deserialize(joinpath(pth, \"tracker_test_data.dat\"))\n", + " # test/test_inputs/tracker/tracker_test_data.dat\n", + " passtimes = deserialize(joinpath(pth, \"passtimes.dat\"))\n", + " props, imgs = deepcopy.([floedata.props, floedata.imgs])\n", "\n", - "latlonimgpth = joinpath(HOME, \"test\", \"test_inputs\", \"NE_Greenland_truecolor.2020162.aqua.250m.tiff\")\n", + " # Add required fields to props\n", + " addfloemasks!(props, imgs)\n", + " add_passtimes!(props, passtimes)\n", + " addψs!(props)\n", + " adduuid!(props)\n", + "end\n", + "\n", + "begin # Filter out floes with area less than `floe_area_threshold` pixels\n", + " floe_area_threshold = 400\n", + " for (i, prop) in enumerate(props)\n", + " props[i] = prop[prop[:, :area].>=floe_area_threshold, :];\n", + " sort!(props[i], :area, rev=true);\n", + " end\n", + "end\n", "\n", - "props, imgs = deepcopy(floedata.props), deepcopy(floedata.imgs);\n", + "# Delete some floes\n", + "deleteat!(props[1], 1); # delete the first floe in day 1 so it doesn't have a match in day 2\n", + "deleteat!(props[2], 5); # delete the fifth floe in day 2 so it doesn't have a match in day 1\n", "\n", - "# Filter out floes with area less than 350 pixels\n", - "for (i, prop) in enumerate(props)\n", - " props[i] = prop[prop[:, :area].>=350, :]\n", - "end" + "# All floes in days 1 and 2 have a match in day 3\n", + "# Expected: 5 trajectories, 3 of length 3 and 2 of length 2\n", + "nothing # suppress output -- not particularly informative. See the next block." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### 4. Pair and label floes" + "### 4. View floe data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Day 1\n", + "props[1][!, [:uuid, :passtime, :area]]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "imshow(imgs[1])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "imshow(props[1][1, :mask])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Day 2\n", + "props[2][!, [:uuid, :passtime, :area]]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "imshow(imgs[2]) # slightly rotated version of the image in day 1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "imshow(props[2][1, :mask])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Day 3\n", + "props[3][!, [:uuid, :passtime, :area]]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "imshow(props[3][1, :mask]) # missing in day 1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "imshow(props[3][5, :mask]) # missing in day 2" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 5. Pair and label floes" ] }, { @@ -109,13 +214,57 @@ "outputs": [], "source": [ "# Get paired floes with labels\n", - "pairs = pairfloes(imgs, props, passtimes, latlonimgpth, condition_thresholds, mc_thresholds)" + "trajectories = long_tracker(props, condition_thresholds, mc_thresholds);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 6. View trajectories and _goodness_ of pairings" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "trajectories[!, [:ID, :passtime, :area_mismatch, :corr]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "7. Add latitude/longitude data to trajectories" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "latlonimgpth = joinpath(HOME, \"test\", \"test_inputs\", \"NE_Greenland_truecolor.2020162.aqua.250m.tiff\")\n", + "addlatlon!(trajectories, latlonimgpth)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# View trajectories with lat/lon data\n", + "cols = [:ID, :area, :passtime, :latitude, :longitude]\n", + "trajectories[!, cols]" ] } ], "metadata": { "kernelspec": { - "display_name": "Julia 1.9.0", + "display_name": "Julia 1.9.4", "language": "julia", "name": "julia-1.9" }, diff --git a/src/IceFloeTracker.jl b/src/IceFloeTracker.jl index 78633f6a..340040b8 100644 --- a/src/IceFloeTracker.jl +++ b/src/IceFloeTracker.jl @@ -116,6 +116,7 @@ include("segmentation_f.jl") include("tracker/tracker-funcs.jl") include("tracker/matchcorr.jl") include("tracker/tracker.jl") +include("tracker/long_tracker.jl") """ MorphSE diff --git a/src/tracker/long_tracker.jl b/src/tracker/long_tracker.jl new file mode 100644 index 00000000..86b944aa --- /dev/null +++ b/src/tracker/long_tracker.jl @@ -0,0 +1,161 @@ +""" + long_tracker(props, condition_thresholds, mc_thresholds) + +Track ice floes over multiple days. + +Trajectories are built in two steps: +0. Get pairs of floes in day 1 and day 2. Any unmatched floes, in both day 1 and day 2, become the "heads" of their respective trajectories. +1. For each subsequent day, find pairs of floes for the current trajectory heads. Again, any unmatched floe in the new prop table starts a new trajectory. + +# Arguments +- `props::Vector{DataFrame}`: A vector of DataFrames, each containing ice floe properties for a single day. Each DataFrame must have the following columns: + - "area" + - "min_row" + - "min_col" + - "max_row" + - "max_col" + - "row_centroid" + - "col_centroid" + - "convex_area" + - "major_axis_length" + - "minor_axis_length" + - "orientation" + - "perimeter" + - "mask": 2D array of booleans + - "ID": unique identifier + - "passtime": A timestamp for the floe + - "psi": the psi-s curve for the floe +- `condition_thresholds`: 3-tuple of thresholds (each a named tuple) for deciding whether to match floe `i` from day `k` to floe j from day `k+1` +- `mc_thresholds`: thresholds for area mismatch and psi-s shape correlation + +# Returns +A DataFrame with the above columns, plus two extra columns, "area_mismatch" and "corr", which are the area mismatch and correlation between a floe and the one that follows it in the trajectory. Trajectories are identified by a unique identifier, "uuid". +""" +function long_tracker(props::Vector{DataFrame}, condition_thresholds, mc_thresholds) + begin # 0th iteration: pair floes in day 1 and day 2 and add unmatched floes to _pairs + props1, props2 = props[1:2] + matched_pairs0 = find_floe_matches(props1, props2, condition_thresholds, mc_thresholds) + + # Get unmatched floes from day 1/2 + unmatched1 = get_unmatched(props1, matched_pairs0.props1) + unmatched2 = get_unmatched(props2, matched_pairs0.props2) + unmatched = vcat(unmatched1, unmatched2) + consolidated_matched_pairs = consolidate_matched_pairs(matched_pairs0) + + # Get _pairs: preliminary matched and unmatched floes + trajectories = vcat(consolidated_matched_pairs, unmatched) + trajectories[:, [:uuid, :passtime, :area_mismatch, :corr]] + end + + begin # Start 3:end iterations + for i in 3:length(props) + trajectory_heads = get_trajectory_heads(trajectories) + new_pairs = IceFloeTracker.find_floe_matches(trajectory_heads, props[i], condition_thresholds, mc_thresholds) + # Get unmatched floes in day 2 (iterations > 2) + unmatched2 = get_unmatched(props[i], new_pairs.props2) + new_pairs = IceFloeTracker.get_matches(new_pairs) + + # Attach new matches and unmatched floes to trajectories + trajectories = vcat(trajectories, new_pairs, unmatched2) + DataFrames.sort!(trajectories, [:uuid, :passtime]) + _swap_last_values!(trajectories) + end + end + IceFloeTracker.reset_id!(trajectories) + trajectories.ID = trajectories.uuid + # list the uuid in the leftmost column + cols = [col for col in names(trajectories) if col ∉ ["ID", "uuid"]] + return trajectories[!, ["ID", cols...]] +end + +""" + find_floe_matches( + tracked, + candidate_props, + condition_thresholds, + mc_thresholds +) + +Find matches for floes in `tracked` from floes in `candidate_props`. + +# Arguments +- `tracked`: dataframe containing floe trajectories. +- `candidate_props`: dataframe containing floe candidate properties. +- `condition_thresholds`: thresholds for deciding whether to match floe `i` from tracked to floe j from `candidate_props` +- `mc_thresholds`: thresholds for area mismatch and psi-s shape correlation +""" +function find_floe_matches( + tracked::T, + candidate_props::T, + condition_thresholds, + mc_thresholds +) where {T<:AbstractDataFrame} + props1 = deepcopy(tracked) + props2 = deepcopy(candidate_props) + match_total = MatchedPairs(props2) + while true # there are no more floes to match in props1 + + # This routine mutates both props1 and props2. + # Get preliminary matches for floe r in props1 from floes in props2 + + # Container for props of matched floe pairs and their similarity ratios. Matches will be updated and added to match_total + matched_pairs = MatchedPairs(props2) + for r in 1:nrow(props1) # TODO: consider using eachrow(props1) to iterate over rows + # 1. Collect preliminary matches for floe r in matching_floes + matching_floes = makeemptydffrom(props2) + + for s in 1:nrow(props2) # TODO: consider using eachrow(props2) to iterate over rows + Δt = get_dt(props1, r, props2, s) + @debug "Considering floe 2:$s for floe 1:$r" + ratios, conditions, dist = compute_ratios_conditions( + (props1, r), (props2, s), Δt, condition_thresholds + ) + + if callmatchcorr(conditions) + @debug "Getting mismatch and correlation for floe 1:$r and floe 2:$s" + (area_mismatch, corr) = matchcorr( + props1.mask[r], props2.mask[s], Δt; mc_thresholds.comp... + ) + + if isfloegoodmatch( + conditions, mc_thresholds.goodness, area_mismatch, corr + ) + @debug "** Found a good match for floe 1:$r => 2:$s" + appendrows!( + matching_floes, + props2[s, :], + (ratios..., area_mismatch, corr), + s, + dist, + ) + @debug "Matching floes" matching_floes + end + end + end # of s for loop + + # 2. Find the best match for floe r + @debug "Finding best match for floe 1:$r" + best_match_idx = getidxmostminimumeverything(matching_floes.ratios) + @debug "Best match index for floe 1:$r: $best_match_idx" + if isnotnan(best_match_idx) + bestmatchdata = getbestmatchdata( + best_match_idx, r, props1, matching_floes + ) # might be copying data unnecessarily + addmatch!(matched_pairs, bestmatchdata) + @debug "Matched pairs" matched_pairs + end + end # of for r = 1:nrow(props1) + + # exit while loop if there are no more floes to match + @debug "Matched pairs" matched_pairs + isempty(matched_pairs) && break + + #= Resolve collisions: + Are there floes in day k+1 paired with more than one + floe in day k? If so, keep the best matching pair and remove all others. =# + resolvecollisions!(matched_pairs) + deletematched!((props1, props2), matched_pairs) + update!(match_total, matched_pairs) + end # of while loop + return match_total +end diff --git a/src/tracker/tracker-funcs.jl b/src/tracker/tracker-funcs.jl index 3cb7b81d..093b55f2 100644 --- a/src/tracker/tracker-funcs.jl +++ b/src/tracker/tracker-funcs.jl @@ -163,7 +163,7 @@ end """ makeemptydffrom(df::DataFrame) -Return an object with an empty dataframe with the same column names as `df` and an empty dataframe with column names `area`, `majoraxis`, `minoraxis`, `convex_area`, `area_mismatch`, and `corr` for similarity ratios. +Return an object with an empty dataframe with the same column names as `df` and an empty dataframe with column names `area`, `majoraxis`, `minoraxis`, `convex_area`, `area_mismatch`, and `corr` for similarity ratios. """ function makeemptydffrom(df::DataFrame) return MatchingProps( @@ -277,7 +277,7 @@ end """ compute_ratios((props_day1, r), (props_day2,s)) -Compute the ratios of the floe properties between the `r`th floe in `props_day1` and the `s`th floe in `props_day2`. Return a tuple of the ratios. +Compute the ratios of the floe properties between the `r`th floe in `props_day1` and the `s`th floe in `props_day2`. Return a tuple of the ratios. # Arguments - `props_day1`: floe properties for day 1 @@ -355,7 +355,7 @@ end Return the floe properties for day `dayidx` and day `dayidx+1`. """ function getpropsday1day2(properties, dayidx::Int64) - return copy(properties[dayidx]), copy(properties[dayidx + 1]) + return copy(properties[dayidx]), copy(properties[dayidx+1]) end """ @@ -364,9 +364,11 @@ end Collect the data for the best match between the `r`th floe in `props_day1` and the `idx`th floe in `matching_floes`. Return a tuple of the floe properties for day 1 and day 2 and the ratios. """ function getbestmatchdata(idx, r, props_day1, matching_floes) + matching_floes_props = matching_floes.props[idx, :] + cols = names(matching_floes_props) return ( - props1=props_day1[r, :], - props2=matching_floes.props[idx, :], + props1=props_day1[r, cols], + props2=matching_floes_props, ratios=matching_floes.ratios[idx, :], dist=matching_floes.dist[idx], ) @@ -404,7 +406,7 @@ Get nonunique rows in `matchedpairs`. """ function getcollisions(matchedpairs) collisions = transform(matchedpairs, nonunique) - return filter(r -> r.x1 != 0, collisions)[:, 1:(end - 1)] + return filter(r -> r.x1 != 0, collisions)[:, 1:(end-1)] end function deletematched!( @@ -498,6 +500,134 @@ function addfloemasks!(props::Vector{DataFrame}, imgs::Vector{<:FloeLabelsImage} return nothing end +""" + get_unmatched(props, matched) + +Return the floes in `props` that are not in `matched`. +""" +function get_unmatched(props, matched) + _on = mapreduce(df -> Set(names(df)), intersect, [props, matched]) |> collect + unmatched = antijoin(props, matched, on=_on) + + # Add missing columns for joining + add_missing = ["area_mismatch", "corr"] + [unmatched[!, n] = [missing for _ in 1:nrow(unmatched)] for n in add_missing] + + return unmatched +end + +""" + get_trajectory_heads(pairs) + +Return the last row (most recent member) of each group (trajectory) in `pairs` as a dataframe. + +This is used for getting the initial floe properties for the next day in search for new pairs. +""" +function get_trajectory_heads(pairs::T) where {T<:AbstractDataFrame} + gdf = groupby(pairs, :uuid) + return combine(gdf, last)[:, names(pairs)] +end + +""" + _swap_last_values!(df) + +Swap the last two values of the `area_mismatch` and `corr` columns for each group in `df`. For bookkeeping purposes for goodness of fit data during the tracking process. +""" +function _swap_last_values!(df) + grouped = groupby(df, :uuid) # Group by uuid + for sdf in grouped + n = nrow(sdf) + if n > 1 + # Swap last two rows for area_mismatch and corr + sdf.area_mismatch[n], sdf.area_mismatch[n-1] = sdf.area_mismatch[n-1], sdf.area_mismatch[n] + sdf.corr[n], sdf.corr[n-1] = sdf.corr[n-1], sdf.corr[n] + end + end + return df # The original DataFrame is modified in-place +end + +""" + get_dt(props1, r, props2, s) + +Return the time difference between the `r`th floe in `props1` and the `s`th floe in `props2` in minutes. +""" +function get_dt(props1, r, props2, s) + return (props2.passtime[s] - props1.passtime[r]) / Minute(1) +end + +""" + adduuid!(props) + +Assign a unique ID to each floe in each table of floe properties. +""" +function adduuid!(props::Vector{DataFrame}) + # Assign a unique ID to each floe in each image + for (i, prop) in enumerate(props) + props[i].uuid = [randstring(12) for _ in 1:nrow(prop)] + end + return nothing +end + +""" + reset_id!(df, col) + +Reset the distinct values in the column `col` of `df` to be consecutive integers starting from 1. +""" +function reset_id!(df::AbstractDataFrame, col::Union{Symbol,AbstractString}=:uuid) + ids = unique(df[!, col]) + _map = Dict(ids .=> 1:length(ids)) + transform!(df, col => ByRow(x -> _map[x]) => col) + return nothing +end + +""" + consolidate_matched_pairs(matched_pairs::MatchedPairs) + +Consolidate the floe properties and similarity ratios of the matched pairs in `matched_pairs` into a single dataframe. Return the consolidated dataframe. Used in iteration `0`. +""" +function consolidate_matched_pairs(matched_pairs::MatchedPairs) + # Ensure UUIDs are consistent + matched_pairs.props2.uuid = matched_pairs.props1.uuid + + # Define columns for goodness ratios + goodness_cols = [:area_mismatch, :corr] + + # Create top DataFrame with properties and goodness ratios + top_df = hcat(matched_pairs.props1, matched_pairs.ratios[:, goodness_cols], makeunique=true) + + # Create missing ratios DataFrame + missing_ratios = similar(matched_pairs.ratios[:, goodness_cols]) + missing_ratios[!, :] .= missing + + bottom_df = hcat(matched_pairs.props2, missing_ratios, makeunique=true) + + combined_df = vcat(top_df, bottom_df) + + DataFrames.sort!(combined_df, [:uuid, :passtime]) + + return combined_df +end + +""" + get_matches(matched_pairs) + +Return a dataframe with the properties and goodness ratios of the matched pairs (right-hand matches) in `matched_pairs`. Used in iterations `1:end`. +""" +function get_matches(matched_pairs::MatchedPairs) + # Ensure UUIDs are consistent + matched_pairs.props2.uuid = matched_pairs.props1.uuid + + # Define columns for goodness ratios + goodness_cols = [:area_mismatch, :corr] + + # Create DataFrame with properties and goodness ratios + combined_df = hcat(matched_pairs.props2, matched_pairs.ratios[:, goodness_cols], makeunique=true) + + DataFrames.sort!(combined_df, [:uuid, :passtime]) + + return combined_df +end + ## LatLon functions originally from IFTPipeline.jl """ @@ -533,7 +663,7 @@ Convert the floe properties from pixels to kilometers and square kilometers wher function converttounits!(propdf, latlondata, colstodrop) if nrow(propdf) == 0 dropcols!(propdf, colstodrop) - insertcols!(propdf, :latitude=>Float64, :longitude=>Float64, :x=>Float64, :y=>Float64) + insertcols!(propdf, :latitude => Float64, :longitude => Float64, :x => Float64, :y => Float64) return nothing end convertcentroid!(propdf, latlondata, colstodrop) diff --git a/test/runtests.jl b/test/runtests.jl index 53ebc1cb..e9cfbf7c 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -13,6 +13,30 @@ using ZipFile include("test_error_rate.jl") include("config.jl") +function pad_string(str::String, total_length::Int=49, padding_char::Char='-') + # Calculate the padding needed on each side + left_padding = div(total_length - length(str), 2) + right_padding = total_length - length(str) - left_padding + + # Pad the string + padded_str = lpad(rpad(str, length(str) + right_padding, padding_char), total_length, padding_char) + + return padded_str +end + +divline = "-"^49 + +macro ntestset(file, testblock) + quote + testset_name = basename($file) + @testset "$testset_name" begin + println(divline) + println(pad_string(testset_name, length(divline))) + $(esc(testblock)) + end + end +end + # Setting things up (see config.jl) ## Get all test files filenames "test-*" in test folder and their corresponding names/label diff --git a/test/test-long-tracker.jl b/test/test-long-tracker.jl new file mode 100644 index 00000000..0a8807bc --- /dev/null +++ b/test/test-long-tracker.jl @@ -0,0 +1,118 @@ +using IceFloeTracker: long_tracker, _imhist + +""" +addgaps(props) + +Add gaps to the props array after the first and before the last day. +""" +function addgaps(props) + blank_props = fill(similar(props[1], 0), rand(1:5)) + + # add gap after first day + props = vcat(props[1:1], blank_props, props[2:end]) + # add gap before last day + props = vcat(props[1:end-1], blank_props, [props[end]]) + return props +end + + +begin # Set thresholds + t1 = (dt=(30.0, 100.0, 1300.0), dist=(200, 250, 300)) + t2 = ( + area=1200, + arearatio=0.28, + majaxisratio=0.10, + minaxisratio=0.12, + convexarearatio=0.14, + ) + t3 = ( + area=10_000, + arearatio=0.18, + majaxisratio=0.1, + minaxisratio=0.15, + convexarearatio=0.2, + ) + condition_thresholds = (t1, t2, t3) + mc_thresholds = ( + goodness=(area3=0.18, area2=0.236, corr=0.68), comp=(mxrot=10, sz=16) + ) +end + + +begin # Load data + pth = joinpath("test_inputs", "tracker") + _floedata = deserialize(joinpath(pth, "tracker_test_data.dat")) + _passtimes = deserialize(joinpath(pth, "passtimes.dat")) + _props, _imgs = deepcopy.([_floedata.props, _floedata.imgs]) + + # This order is important: masks, uuids, passtimes, ψs + IceFloeTracker.addfloemasks!(_props, _imgs) + IceFloeTracker.addψs!(_props) + IceFloeTracker.add_passtimes!(_props, _passtimes) + IceFloeTracker.adduuid!(_props) +end + +begin # Filter out floes with area less than `floe_area_threshold` pixels + floe_area_threshold = 400 + for (i, prop) in enumerate(_props) + _props[i] = prop[prop[:, :area].>=floe_area_threshold, :] # 500 working good + sort!(_props[i], :area, rev=true) + end +end + +@ntestset "$(@__FILE__)" begin + @ntestset "Case 1" begin + # Every floe is matched in every day + props_test_case1 = deepcopy(_props) + trajectories = IceFloeTracker.long_tracker(props_test_case1, condition_thresholds, mc_thresholds) + + # Expected: 5 trajectories, all of which have length 3 + IDs = trajectories[!, :ID] + ids, counts = _imhist(IDs, unique(IDs)) + @test maximum(ids) == 5 + + ids, counts = _imhist(counts, unique(counts)) + @test ids == [3] + @test counts == [5] + end + + begin # Unmatched floe in day 1, unmatched floe in day 2, and matches for every floe starting in day 3 + props_test_case2 = deepcopy(_props) + delete!(props_test_case2[1], 1) + delete!(props_test_case2[2], 5) + end + + @ntestset "Case 2" begin + trajectories = IceFloeTracker.long_tracker(props_test_case2, condition_thresholds, mc_thresholds) + + # Expected: 5 trajectories, 3 of which have length 3 and 2 of which have length 2 + IDs = trajectories[!, :ID] + @test IDs == [1, 1, 1, 2, 2, 3, 3, 3, 4, 4, 5, 5, 5] + end + + @ntestset "Test gaps" begin + @ntestset "Case 3" begin + # Every floe is matched in every day for which there is data + + props = addgaps(_props) + + trajectories = IceFloeTracker.long_tracker(props, condition_thresholds, mc_thresholds) + + # Expected: 5 trajectories, all of which have length 3 as in test case 1 + IDs = trajectories[!, :ID] + @test IDs == [1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5] + end + + @ntestset "Case 4" begin + # Add gaps to props_test_case2 + props = addgaps(props_test_case2) + + trajectories = IceFloeTracker.long_tracker(props, condition_thresholds, mc_thresholds) + + # Expected: 5 trajectories, 3 of which have length 3 and 2 of which have length 2 as in test case 2 + IDs = trajectories[!, :ID] + @test IDs == [1, 1, 1, 2, 2, 3, 3, 3, 4, 4, 5, 5, 5] + end + end + +end diff --git a/test/test-matchcorr.jl b/test/test-matchcorr.jl index c2343fc9..aa6fe284 100644 --- a/test/test-matchcorr.jl +++ b/test/test-matchcorr.jl @@ -1,4 +1,4 @@ -@testset "tracker/matchcorr" begin +@ntestset "$(@__FILE__)" begin path = joinpath(test_data_dir, "tracker") @testset "matchcorr" begin @@ -10,61 +10,4 @@ @test isapprox(mm, 0.0; atol=0.05) && isapprox(c, 0.99; atol=0.05) @test all(isnan.(collect(matchcorr(floes[3], floes[4], 400.0)))) end - - @testset "tracker" begin - # Set thresholds - t1 = (dt=(30.0, 100.0, 1300.0), dist=(200, 250, 300)) - t2 = ( - area=1200, - arearatio=0.28, - majaxisratio=0.10, - minaxisratio=0.12, - convexarearatio=0.14, - ) - t3 = ( - area=10_000, - arearatio=0.18, - majaxisratio=0.1, - minaxisratio=0.15, - convexarearatio=0.2, - ) - condition_thresholds = (t1, t2, t3) - mc_thresholds = ( - goodness=(area3=0.18, area2=0.236, corr=0.68), comp=(mxrot=10, sz=16) - ) - dt = [15.0, 20.0] - - # Load data - data = deserialize(joinpath(path, "tracker_test_data.dat")) - passtimes = deserialize(joinpath(path, "passtimes.dat")) - latlonimgpth = "test_inputs/NE_Greenland_truecolor.2020162.aqua.250m.tiff" - - # Filtering out small floes. Algorithm performs poorly on small, amorphous floes as they seem to look similar (too `blobby`) to each other - for (i, prop) in enumerate(data.props) - data.props[i] = prop[prop[:, :area].>=350, :] - end - - _pairs = IceFloeTracker.pairfloes( - data.imgs, data.props, passtimes, latlonimgpth, condition_thresholds, mc_thresholds - ) - - @test maximum(_pairs.ID) == 6 - expectedcols = ["ID", - "passtime", - "area", - "convex_area", - "major_axis_length", - "minor_axis_length", - "orientation", - "perimeter", - "area_mismatch", - "corr", - "latitude", - "longitude", - "x", - "y"] - @test all([name in expectedcols for name in names(_pairs)]) - @test issorted(_pairs, :ID) - @test all([issorted(grp, :passtime) for grp in groupby(_pairs, :ID)]) - end end