02:00
Facial Expression Recognition has advanced significantly with the emergence of automatic emotion recognition systems.
However, the outputs of these systems can be difficult to handle:
In this workshop, we will explore how to process and analyse such data using R.
78 participants (20 males, 58 females) have been recorded while they were asked to express six emotions and constitute de BU-4DFE database (Yin et al., 2008).
A total of 467 video recordings were obtained (one participant completed only five videos) and analysed using Affectiva’s Affedex Emotion Recognition system integrated within the iMotions Lab software.
Each video frame was scored for the likelihood of expressing the following emotions: Happiness, Surprise, Disgust, Fear, Sadness, and Anger, with values from 0 (not recognised) to 1 (fully recognised).
Did the participants express the emotion intended by the instructions?
There is little difference between R and Python for research purposes, but R is, in my view, easier to read and write.
This workshop assumes some familiarity with R, particularly:
|> rather than the %>% pipe from the {magrittr} packageNote
The pipe operator applies the object on the left-hand side to the first argument of the function on the right-hand side.
So instead of writing f(arg1 = x, arg2 = y), you write x |> f(arg2 = y).
Although you may use your own R installation, there are excellent and free cloud-based options:
Warning
The free tier on Posit Cloud provides only 25 hours of usage per month.
https://github.com/damien-dupre/cere2025_workshop when prompted for the repository URL02:00
data/ folderscripts/ folderoutput/ foldercere2025_workshop/ folder structure
cere2025_workshop/
├── data/
│ ├── F001_Angry.csv
│ ├── F001_Disgust.csv
│ └── ...
├── scripts/
│ ├── 1_import_tidy_transform.R
│ ├── 2_visualise.R
│ ├── 3_model.R
│ └── 4_communicate.R
└── output/
├── slides.html
├── slides.qmd
├── slides_files/
└── ...
Start by installing the necessary packages:
install.packages("tidyverse") # Metapackage for data transformation and visualisation
install.packages("fs") # Manipulate files' and folders' path
install.packages("here") # Rebase the origin of the repository regardless of the system
install.packages("report") # Standardize the output of statistical modelsAnd load them:
We will combine all .csv files into a single data frame:
Preview the 5 first rows of the df object:
# A tibble: 5 × 8
source frame anger disgust fear happiness sadness surprise
<chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1 /Users/damienhome/Dr… 1 2 e-5 0.00426 4.6 e-5 0.000018 2.4 e-4 0.00194
2 /Users/damienhome/Dr… 2 2.00e-5 0.00426 4.60e-5 0.0000180 2.39e-4 0.00194
3 /Users/damienhome/Dr… 3 2.00e-5 0.00426 4.60e-5 0.0000180 2.40e-4 0.00194
4 /Users/damienhome/Dr… 4 2.00e-5 0.00426 4.60e-5 0.0000180 2.40e-4 0.00194
5 /Users/damienhome/Dr… 5 2.00e-5 0.00426 4.60e-5 0.0000180 2.39e-4 0.00194
We will update the source variable to retain only the file name:
Importantly, we need to transform this wide dataframe (i.e., all emotion variables are side by side) to a long dataframe (i.e., all emotion variables are below each others and only one “value” variable is used):
The df_tidy_long object has 280200 rows, let’s look at its first rows:
# A tibble: 10 × 6
frame file ppt instruction emotion recognition
<dbl> <chr> <chr> <chr> <chr> <dbl>
1 1 F001_Angry F001 Angry anger 0.00002
2 1 F001_Angry F001 Angry disgust 0.00426
3 1 F001_Angry F001 Angry fear 0.000046
4 1 F001_Angry F001 Angry happiness 0.000018
5 1 F001_Angry F001 Angry sadness 0.00024
6 1 F001_Angry F001 Angry surprise 0.00194
7 2 F001_Angry F001 Angry anger 0.0000200
8 2 F001_Angry F001 Angry disgust 0.00426
9 2 F001_Angry F001 Angry fear 0.0000460
10 2 F001_Angry F001 Angry happiness 0.0000180
scripts/ folderdf_tidy_long objectNote
Instead of clicking on the Run you can also press:
02:00
Let’s visualise a single video:
list_file <- unique(df_tidy_long$file)
df_tidy_long |>
filter(file == list_file[3]) |>
ggplot() +
aes(
x = frame,
y = recognition,
colour = emotion
) +
geom_line(linewidth = 2) +
theme_bw() +
theme(legend.position = "bottom") +
scale_y_continuous(limits = c(0, 1)) +
scale_color_brewer(palette = "Dark2")
Let’s visualise recordings for one instruction task:
list_task <- unique(df_tidy_long$instruction)
df_tidy_long |>
filter(instruction == list_task[3]) |>
ggplot() +
aes(
x = frame,
y = recognition,
group = ppt,
colour = emotion
) +
geom_line(linewidth = 1, alpha = 0.2) +
facet_grid(emotion ~ ., switch = "x") +
theme_bw() +
theme(legend.position = "bottom") +
scale_y_continuous(breaks = c(0, 0.5, 1)) +
scale_color_brewer(palette = "Dark2") +
guides(colour = "none")
scripts/ folderWarning
The code in the script “1_import_tidy_transform.R” must have been ran before running the code in the script “2_visualise.R”.
02:00
For each video, we aim to identify the expressed emotion using three distinct methods (as described in Dupré, 2021):
Here is a visual representation of each method applied to a special case in which all methods return the same emotion recognised.
However, some cases are returning different results:


Each of these methods has its advantages and disadvantages. For instance, Confidence Score and Frame Score may offer greater robustness against artefacts.
There may also be alternative calculation methods not included in this discussion.
Lastly, assigning a single label to an entire video is a simplification that could be questioned, though this issue lies outside the scope of the workshop.
The emotion recognised is the one having the highest value in the recording
df_score_matching <- df_tidy_long |>
# keep only the frame with the highest value in each ties
group_by(file) |>
filter(recognition == max(recognition)) |>
# in case of ties, label the emotions "undetermined" and remove duplicates
add_count() |>
mutate(emotion = case_when(n != 1 ~ "undetermined", .default = emotion)) |>
select(file, emotion) |>
distinct() |>
# label the method
mutate(method = "matching score")The emotion recognised is the one with the highest average along all the recording among the possible emotions
df_score_confidence <- df_tidy_long |>
# calculate the average value for each emotion in each file and keep the highest
group_by(file, emotion) |>
summarise(mean_emotion = mean(recognition, na.rm = TRUE)) |>
slice_max(mean_emotion) |>
# in case of ties, label the emotions "undetermined" and remove duplicates
add_count() |>
mutate(emotion = case_when(n != 1 ~ "undetermined", .default = emotion)) |>
select(file, emotion) |>
distinct() |>
# label the method
mutate(method = "confidence score")Identify the emotion recognised in each frame (max value) and to count how many time each have been recognised in a video
df_score_frame <- df_tidy_long |>
group_by(file, frame) |> # in each file, for each frame, find the highest value
slice_max(recognition) |>
add_count(name = "n_frame") |> # in case of ties, label the emotions "undetermined" and remove duplicates
mutate(emotion = case_when(n_frame != 1 ~ "undetermined", .default = emotion)) |>
select(file, frame, emotion) |>
distinct() |>
group_by(file, emotion) |> # count the occurrence of each emotion across all frames and select highest
count() |>
group_by(file) |>
slice_max(n) |>
add_count(name = "n_file") |> # in case of ties, label the emotions "undetermined" and remove duplicates
mutate(emotion = case_when(n_file != 1 ~ "undetermined", .default = emotion)) |>
select(file, emotion) |>
distinct() |>
mutate(method = "frame score") # label the methodNow a label has been assigned to each recorded video using 3 different calculation methods, we can compare these score with the “ground truth” (i.e., the type of emotion supposedly elicited).
df_congruency <-
bind_rows(
df_score_matching,
df_score_confidence,
df_score_frame
) |>
separate(col = file, into = c("ppt", "instruction"), sep = "_", remove = FALSE) |>
mutate(
instruction = instruction |>
tolower() |>
str_replace_all(c("happy" = "happiness", "sad" = "sadness", "angry" = "anger")),
congruency = if_else(instruction == emotion, 1, 0)
)A Generalised Linear Model using a binomial distribution of the residuals (logistic regression) is fitted to identify the effects of calculation methods, instruction tasks, and the interaction between both.
To obtain their omnibus effect estimates, an analysis of variance is used on the GLM model.
scripts/ folderWarning
The code in the script “1_import_tidy_transform.R” must have been ran before running the code in the script “3_model.R”.
02:00
Let’s calculate the average congruency by instruction task and by method with a basic visualisation:
df_congruency |>
group_by(method, instruction) |>
summarise(mean_se(congruency)) |>
ggplot() +
aes(
x = instruction,
y = y,
ymin = ymin,
ymax = ymax,
fill = method,
shape = method
) +
geom_errorbar(position = position_dodge(width = 0.8)) +
geom_point(
size = 4,
position = position_dodge(width = 0.8)
) +
theme_bw() +
theme(legend.position = "bottom")
Here is the same visualisation but with more customisations:
df_congruency |>
group_by(method, instruction) |>
summarise(mean_se(congruency)) |>
ggplot() +
aes(
x = fct_reorder(instruction, y, .fun = "mean"),
y = y,
ymin = ymin,
ymax = ymax,
fill = method,
shape = method
) +
ggstats::geom_stripped_cols() +
geom_errorbar(width = 0, position = position_dodge(width = 0.8)) +
geom_point(stroke = 0, size = 4, position = position_dodge(width = 0.8)) +
scale_y_continuous("Congruence between instruction and recognition", limits = c(0, 1), labels = scales::percent) +
scale_x_discrete("") +
scale_fill_brewer("Method", palette = "Dark2") +
scale_shape_manual("Method", values = c(21, 22, 23, 24)) +
guides(
shape = guide_legend(reverse = TRUE, position = "inside"),
fill = guide_legend(reverse = TRUE, position = "inside")
) +
theme_bw() +
theme(
text = element_text(size = 12),
axis.text.x = element_text(size = 14),
axis.text.y = element_text(size = 14),
axis.line.y = element_blank(),
legend.title = element_text(hjust = 0.5),
legend.position.inside = c(0.8, 0.2),
legend.background = element_rect(fill = "grey80")
) +
coord_flip(ylim = c(0, 1)) 
The ANOVA (formula: congruency ~ method * instruction) suggests that:
Effect sizes were labelled following Field’s (2013) recommendations.
scripts/ folderWarning
The code in the scripts “1_import_tidy_transform.R” and “3_model.R” must have been ran before running the code in the script “4_communicate.R”.
02:00
|> pipe operator make code more readable and teachableAll three methods use a relative indicator rather than an absolute threshold to identify the emotion recognised:
Method performance differs:

Thanks for your attention and don’t hesitate to ask if you have any questions!
@damien_dupre
@damien-dupre
https://damien-dupre.github.io
damien.dupre@dcu.ie

Damien Dupré - CERE2025