R/verisense_count_steps.R

Defines functions verisense_count_steps_revised verisense_count_steps create_peak_info find_peak_location_fast rowWhichMaxIndex rowWhichMaxs find_peak_location

Documented in verisense_count_steps verisense_count_steps_revised

find_peak_location = function(segments, acc, n_samples, k, half_k, peak_info) {
  # for each segment find the peak location
  for (i in 1:segments) {
    start_idx <- (i - 1) * k + 1
    end_idx <- start_idx + (k - 1)
    acc_values = acc[start_idx:end_idx]
    tmp_loc_a <- which.max(acc_values)
    tmp_loc_b <- (i - 1) * k + tmp_loc_a
    # only save if this is a peak value in range of -k/2:+K/2
    start_idx_ctr <- max(tmp_loc_b - half_k, 1)
    end_idx_ctr <- min(tmp_loc_b + half_k, n_samples)
    check_loc <- which.max(acc[start_idx_ctr:end_idx_ctr])
    if (check_loc == (half_k + 1)) {
      peak_info[i, "peak_location"] <- tmp_loc_b
      peak_info[i, "acc_magnitude"] <- max(acc_values)
    }
  }
  return(peak_info)
}

rowWhichMaxs = function(x, tol = 1e-13) {
  rmax = matrixStats::rowMaxs(x, na.rm = TRUE)
  x = abs(x - rmax) < tol
  x[!x] = NA
  index = which(x, arr.ind = TRUE)
  index = as.data.frame(index)
  all_rows = data.frame(row = 1:nrow(x))
  index = merge(all_rows, index, by = "row", all.x = TRUE)
  index = index[order(index[, "row"]), ]
  res = tapply(index$col, index$row, min)
  res = unname(res)
  res = c(unlist(res))
  # index[, "col"]
}

rowWhichMaxIndex = function(index_mat, value_mat, tol = 1e-13) {
  # value_mat[is.na(value_mat)] = -Inf
  rmax = matrixStats::rowMaxs(value_mat, na.rm = TRUE)

  value_mat = abs(value_mat - rmax) < tol
  value_mat[!value_mat] = NA
  index_mat = index_mat * value_mat
  result = matrixStats::rowMins(index_mat, na.rm = TRUE)
  result[!is.finite(result)] = NA
  result
}




find_peak_location_fast = function(segments, acc, n_samples,
                                   k, half_k, peak_info) {
  segment = row_num = index = NULL
  rm(list = c("index", "row_num", "segment"))
  stopifnot(nrow(peak_info) == segments)
  # for each segment find the peak location
  df = data.frame(acc = acc)

  # create the windows so they can be reshaped
  df = df %>%
    dplyr::mutate(
      row_num = 1:nrow(df),
      segment = cumsum((row_num %% k) == 1),
      index = rep(1:k, length = nrow(df)))
  df = df %>%
    dplyr::filter(segment <= segments)
  # make the indices on the whole acc
  wide_index = df %>%
    dplyr::select(-acc) %>%
    tidyr::pivot_wider(
      values_from = row_num,
      names_from = index,
      # need fill for last segment
      values_fill = NA)
  rm(df)
  wide_index = wide_index %>%
    dplyr::select(-dplyr::any_of("segment")) %>%
    as.matrix()
  stopifnot(nrow(wide_index) == nrow(peak_info))

  if (FALSE) {
    # old way of doing things - not needed
    # wide = df %>%
    #   dplyr::select(-row_num) %>%
    #   tidyr::pivot_wider(values_from = acc, names_from = index,
    #                      # need fill for last segment
    #                      values_fill = -100)
    # wide = wide %>%
    #   dplyr::select(-dplyr::any_of("segment")) %>%
    #   as.matrix()
  } else {
    wide = array(acc[wide_index], dim = dim(wide_index))
    wide[is.na(wide)] = -10000
  }

  # will use this later
  rmax = matrixStats::rowMaxs(wide, na.rm = TRUE)
  # using function now - but this is what it did
  # rmax = matrixStats::rowMaxs(wide, na.rm = TRUE)
  # wide = abs(wide - rmax) < 1e-13
  # wide[!wide] = NA
  # wide_index = wide_index * wide
  # tmp_loc_b = matrixStats::rowMins(wide_index, na.rm = TRUE)


  tmp_loc_b = rowWhichMaxIndex(index_mat = wide_index, value_mat = wide)
  from = tmp_loc_b - half_k
  to = tmp_loc_b + half_k

  # get the new windows based on peak of original window
  new_index = array(dim = c(nrow(wide), half_k * 2 + 1))
  new_index[, 1] = from
  for (icol in seq(2, ncol(new_index))) {
    new_index[,icol] = new_index[ ,icol - 1] + 1
  }
  stopifnot(isTRUE(all.equal(new_index[, ncol(new_index)], to)))

  # repeat the same values
  new_index[new_index < 1] = NA
  new_index[new_index > n_samples] = NA

  new_wide = array(acc[new_index], dim = dim(new_index))
  check_loc = rowWhichMaxIndex(new_index, new_wide)

  # if they are the same index, then it's a peak!
  keep = check_loc == tmp_loc_b
  peak_info[keep, "peak_location"] <- tmp_loc_b[keep]
  peak_info[keep, "acc_magnitude"] <- rmax[keep]
  return(peak_info)
}

create_peak_info = function(segments) {
  peak_info <- matrix(NA, nrow = segments, ncol = 5)
  colnames(peak_info) <- c(
    "peak_location",
    "acc_magnitude",
    "periodicity",
    "similarity",
    "continuity"
  )
  peak_info <- as.data.frame(peak_info)
  # peak_info[,1] - peak location
  # peak_info[,2] - acc magnitude
  # peak_info[,3] - periodicity (samples)
  # peak_info[,4] - similarity
  # peak_info[,5] - continuity
  peak_info
}

#' Count Steps According to Gu et al, 2017 Method
#'
#' This method is based off finding peaks in the summed and squared acceleration signal
#' and then using multiple thresholds to determine if each peak is a step or an artifact.
#' An additional magnitude threshold was added to the algorithm to prevent false positives
#' in free living data.
#'
#' @param data A `data.frame` with a column for time in `POSIXct` (usually
#' `HEADER_TIMESTAMP`, not required), and `X`, `Y`, `Z`
#' @param sample_rate sampling frequency of the input data
#' @param k window size for controlling peak finding.
#' @param periodicity_range a length-2 vector of the range of periodicity.
#' These are integers that represent **samples** not seconds.
#' @param similarity_threshold threshold (in g) for similarity between
#' magnitude of peaks
#' @param continuity_window_size Window size for continuity
#' @param continuity_threshold Threshold for continuity
#' @param variance_threshold Variance threshold for the signal
#' @param vm_threshold vector magnitude threshold for a peak to be
#' called a peak
#' @param peak_finder function to find peaks, either the "original"
#' from the code, or the optimized "fast" version.
#' @param verbose print diagnostic messages
#' @param global_vm_threshold Global acceleration VM threshold (in standard
#' deviation) for the total vector.  If `sd(acc) < thresh` no steps are
#' estimated.  Set to `0` to run estimation regardless.
#'
#' @return A vector of length `round(nrow(input_data) / sample_rate)` of the
#' estimated steps, where the data is rounded to seconds
#' @export
#'
#' @author Matthew R Patterson <mpatterson@shimmersensing.com>,
#' MIT license, Copyright (c) 2020 Shimmer
#'
#' @examples
#' csv_file = system.file("test_data_bout.csv", package = "walking")
#' if (requireNamespace("readr", quietly = TRUE)) {
#'   x = readr::read_csv(csv_file)
#'   colnames(x)[colnames(x) == "UTC time"] = "time"
#'   out = verisense_count_steps(x, sample_rate = 10L)
#' }
#' input_data <- matrix(runif(500 * 3, min = -1.5, max = 1.5), ncol = 3)
#' verisense_count_steps(input_data, sample_rate = 15L)
#' verisense_count_steps(input_data, sample_rate = 15L, peak_finder = "fast")
#' acc = sqrt(rowSums(input_data^2))
#' verisense_count_steps(acc, sample_rate = 15L, peak_finder = "fast")
verisense_count_steps <- function(
    data,
    sample_rate,
    k = 3, # window size
    periodicity_range = c(5, 15),
    similarity_threshold = -0.5,
    continuity_window_size = 4,
    continuity_threshold = 4,
    variance_threshold = 0.001,
    vm_threshold = 1.2,
    peak_finder = c("fast", "original"),
    verbose = TRUE,
    global_vm_threshold = 0.025
) {

  if (is.vector(data) && is.numeric(data)) {
    warning("Assuming data is a vector of VM!")
    acc = data
  } else {
    data = actibase::acti_standardize_data(data, subset_xyz = TRUE)

    acc <- sqrt(data$X^2 + data$Y^2 + data$Z^2)
    rm(data)
  }
  assertthat::assert_that(
    assertthat::is.count(sample_rate)
  )

  n_samples = length(acc)
  if (stats::sd(acc) < global_vm_threshold) {
    # acceleration too low, no steps
    num_seconds <- round(n_samples / sample_rate)
    steps_per_sec <- rep(0, num_seconds)
    return(steps_per_sec)
  }

  assertthat::assert_that(
    assertthat::is.scalar(k),
    # ncol(data) == 3,
    length(periodicity_range) == 2,
    assertthat::is.scalar(similarity_threshold),
    assertthat::is.count(continuity_window_size),
    assertthat::is.count(continuity_threshold),
    assertthat::is.scalar(variance_threshold),
    assertthat::is.scalar(vm_threshold)
  )
  periodicity_range = sort(periodicity_range)
  assertthat::assert_that(
    diff(periodicity_range) > 0
  )
  period_min = periodicity_range[1]
  period_max = periodicity_range[2]

  # find the peak rms value is every range of k
  half_k <- round(k / 2)

  segments <- floor(n_samples / k)
  peak_info = create_peak_info(segments)

  # different methods to run the peak
  peak_finder = match.arg(peak_finder)
  peak_func = switch(
    peak_finder,
    original = find_peak_location,
    fast = find_peak_location_fast
  )
  if (verbose) {
    message("Finding Peak Locations")
  }
  peak_info = peak_func(
    segments = segments,
    acc = acc,
    n_samples = n_samples,
    k = k,
    half_k = half_k,
    peak_info = peak_info)
  peak_info <- peak_info[!is.na(peak_info[, "peak_location"]), ] # get rid of na rows

  # filter peak_info[,2] based on vm_threshold
  if (verbose) {
    message("Thresholding Peak by VM Threshold")
  }
  peak_info <- peak_info[peak_info[, "acc_magnitude"] > vm_threshold, ]
  if (verbose) {
    message("Thresholding Peak by Periodicity")
  }
  no_steps <- FALSE
  if (nrow(peak_info) > 2) { # there must be at least two steps
    num_peaks <- nrow(peak_info)
    # Calculate Features (periodicity, similarity, continuity)
    peak_info[1:(num_peaks - 1), "periodicity"] <- diff(peak_info[, "peak_location"]) # calculate periodicity
    na_periodicity = is.na(peak_info[, "periodicity"])
    peak_info <- peak_info[
      !na_periodicity &
        peak_info[, "periodicity"] > period_min &
        peak_info[, "periodicity"] < period_max, ] # filter peaks based on period_min
  } else {
    no_steps <- TRUE
  }

  if (nrow(peak_info) == 0 ||
      nrow(peak_info) <= 2 || # can't calculate similarity
      no_steps == TRUE) {
    # no steps found
    num_seconds <- round(n_samples / sample_rate)
    steps_per_sec <- rep(0, num_seconds)
    return(steps_per_sec)
  }

  # calculate similarity
  if (verbose) {
    message("Thresholding Peak by Similarity")
  }
  num_peaks <- nrow(peak_info)
  peak_info[1:(num_peaks - 2), "similarity"] <- -abs(diff(peak_info[, "acc_magnitude"], 2)) # calculate similarity
  peak_info = peak_info[!is.na(peak_info$similarity), ]
  peak_info <- peak_info[peak_info[, "similarity"] > similarity_threshold, ] # filter based on sim_thres

  # calculate continuity
  if (verbose) {
    message("Thresholding Peak by Continuity")
  }
  peak_info[, "continuity"] = 0
  if (nrow(peak_info) > 5) {
    end_for <- nrow(peak_info) - 1
    for (i in continuity_window_size:end_for) {
      # for each bw peak period calculate acc var
      v_count <- 0 # count how many windows were over the variance threshold
      for (x in 1:continuity_window_size) {
        index = peak_info[i - x + 1, "peak_location"]:peak_info[i - x + 2, "peak_location"]
        sub_acc = acc[index]
        if (stats::var(sub_acc) > variance_threshold) {
          v_count <- v_count + 1
        }
      }
      if (v_count >= continuity_threshold) {
        peak_info[i, "continuity"] <- 1 # set continuity to 1, otherwise, 0
      }
    }
  }
  peak_info <- peak_info[peak_info[, "continuity"] == 1, ] # continuity test - only keep locations after this
  all_peak_info = peak_info
  peak_info = peak_info$peak_location
  peak_info <- peak_info[!is.na(peak_info)] # previous statement can result in an NA in col-1

  if (length(peak_info) == 0) {
    # no steps found
    num_seconds <- round(n_samples / sample_rate)
    steps_per_sec <- rep(0, num_seconds)
    return(steps_per_sec)
  } else {
    # for GGIR, output the number of steps in 1 second chunks
    start_idx_vec <- seq(from = 1, to = n_samples, by = sample_rate)
    steps_per_sec <- table(factor(findInterval(peak_info, start_idx_vec),
                                  levels = seq_along(start_idx_vec)))
    steps_per_sec <- as.numeric(steps_per_sec)
  }


  return(steps_per_sec)
}



#' @export
#' @param ... not used, used to passes arguments from
#' `verisense_count_steps_revised` to `verisense_count_steps`
#' @rdname verisense_count_steps
#' @note the `_revised` version is the same algorithm with different defaults
#' for the parameters as based on <doi:10.3390/s22249984>.
verisense_count_steps_revised <- function(
    ...,
    k = 4, # window size
    periodicity_range = c(4, 20),
    similarity_threshold = -1,
    continuity_window_size = 4,
    continuity_threshold = 4,
    variance_threshold = 0.01,
    vm_threshold = 1.25
) {
  verisense_count_steps(
    ...,
    k = k,
    periodicity_range = periodicity_range,
    similarity_threshold = similarity_threshold,
    continuity_window_size = continuity_window_size,
    continuity_threshold = continuity_threshold,
    variance_threshold = variance_threshold,
    vm_threshold = vm_threshold
  )
}

Try the walking package in your browser

Any scripts or data that you put into this service are public.

walking documentation built on Aug. 4, 2026, 1:07 a.m.