Compare commits

...

8 Commits

Author SHA1 Message Date
Matt
524b135142 Fix OpenCV load in simuated robot projects (#1001) 2023-11-05 15:13:00 -08:00
Sriman Achanta
623b4e5b84 Show Saved Snapshots in UI (#995)
Add Camera Control tab to Cameras for the button to live in
2023-11-05 11:33:45 -05:00
amquake
9370937280 [photon-lib] Make PhotonPoseEstimator coprocessor multitag result relative to tag layout origin (#997)
Fixes #991.
2023-11-04 12:25:49 -04:00
Matt
7eb4645ee2 Bump wpilib to beta-3 (#998) 2023-11-04 09:42:30 -04:00
superpenguin612
5136dad535 Add StickyFPS quirk for Arducam OV2311 (#994)
Disable setting first video mode on boot. Resolution is actually set immediately after
2023-11-02 22:36:24 -04:00
Matt
5a4eb54693 Check if WS is closing before sending message (#993) 2023-11-02 19:34:31 -04:00
Matt
12774591a4 Bump opencv to fix cross-compile rpath (#992) 2023-11-01 16:16:41 -07:00
Matt
6666b22fc1 Fix trailing quotes in readme (#990)
* Fix trailing quotes in readme

* Update README.md
2023-10-31 00:10:57 -04:00
21 changed files with 323 additions and 47 deletions

View File

@@ -22,10 +22,10 @@ Note that these are case sensitive!
* linuxathena
* linuxarm32
* linuxarm64
* arm32"
* arm64"
* x86-64"
* x86"
* arm32
* arm64
* x86-64
* x86
- `-PtgtIp`: Specifies where `./gradlew deploy` should try to copy the fat JAR to
- `-Pprofile`: enables JVM profiling

View File

@@ -2,7 +2,7 @@ plugins {
id "com.diffplug.spotless" version "6.22.0"
id "edu.wpi.first.NativeUtils" version "2024.2.0" apply false
id "edu.wpi.first.wpilib.repositories.WPILibRepositoriesPlugin" version "2020.2"
id "edu.wpi.first.GradleRIO" version "2024.1.1-beta-2"
id "edu.wpi.first.GradleRIO" version "2024.1.1-beta-3"
id 'edu.wpi.first.WpilibTools' version '1.3.0'
}
@@ -20,8 +20,8 @@ allprojects {
apply from: "versioningHelper.gradle"
ext {
wpilibVersion = "2024.1.1-beta-2"
openCVversion = "4.8.0-1"
wpilibVersion = "2024.1.1-beta-3"
openCVversion = "4.8.0-2"
joglVersion = "2.4.0-rc-20200307"
javalinVersion = "5.6.2"
frcYear = "2024"
@@ -41,6 +41,11 @@ ext {
wpilibTools.deps.wpilibVersion = wpilibVersion
// Tell gradlerio what version of things to use (that we care about)
// See: https://github.com/wpilibsuite/GradleRIO/blob/main/src/main/java/edu/wpi/first/gradlerio/wpi/WPIVersionsExtension.java
wpi.getVersions().getOpencvVersion().convention(openCVversion);
wpi.getVersions().getWpilibVersion().convention(wpilibVersion);
spotless {
java {
target fileTree('.') {

View File

@@ -7,3 +7,14 @@ $heading-font-family: $default-font;
.v-application {
font-family: $default-font !important;
}
.v-row-group__header {
background: #005281 !important;
}
.theme--dark.v-data-table
> .v-data-table__wrapper
> table
> tbody
> tr:hover:not(.v-data-table__expanded__content):not(.v-data-table__empty-wrapper) {
background: #005281 !important;
}

View File

@@ -218,7 +218,7 @@ const endCalibration = () => {
<template>
<div>
<v-card class="pr-6 pb-3" color="primary" dark>
<v-card class="mb-3 pr-6 pb-3" color="primary" dark>
<v-card-title>Camera Calibration</v-card-title>
<div class="ml-5">
<v-row>

View File

@@ -0,0 +1,202 @@
<script setup lang="ts">
import { ref } from "vue";
import axios from "axios";
import { useStateStore } from "@/stores/StateStore";
interface SnapshotMetadata {
snapshotName: string;
cameraNickname: string;
streamType: "input" | "output";
timeCreated: Date;
}
const getSnapshotMetadataFromName = (snapshotName: string): SnapshotMetadata => {
snapshotName = snapshotName.replace(/\.[^/.]+$/, "");
const data = snapshotName.split("_");
const cameraName = data.slice(0, data.length - 2).join("_");
const streamType = data[data.length - 2] as "input" | "output";
const dateStr = data[data.length - 1];
const year = parseInt(dateStr.substring(0, 4), 10);
const month = parseInt(dateStr.substring(5, 7), 10) - 1; // Months are zero-based
const day = parseInt(dateStr.substring(8, 10), 10);
const hours = parseInt(dateStr.substring(11, 13), 10);
const minutes = parseInt(dateStr.substring(13, 15), 10);
const seconds = parseInt(dateStr.substring(15, 17), 10);
const milliseconds = parseInt(dateStr.substring(17), 10);
return {
snapshotName: snapshotName,
cameraNickname: cameraName,
streamType: streamType,
timeCreated: new Date(year, month, day, hours, minutes, seconds, milliseconds)
};
};
interface Snapshot {
index: number;
snapshotName: string;
snapshotShortName: string;
cameraUniqueName: string;
cameraNickname: string;
streamType: "input" | "output";
timeCreated: Date;
snapshotSrc: string;
}
const imgData = ref<Snapshot[]>([]);
const fetchSnapshots = () => {
axios
.get("/utils/getImageSnapshots")
.then((response) => {
imgData.value = response.data.map(
(snapshotData: { snapshotName: string; cameraUniqueName: string; snapshotData: string }, index) => {
const metadata = getSnapshotMetadataFromName(snapshotData.snapshotName);
return {
index: index,
snapshotName: snapshotData.snapshotName,
snapshotShortName: metadata.snapshotName,
cameraUniqueName: snapshotData.cameraUniqueName,
cameraNickname: metadata.cameraNickname,
streamType: metadata.streamType,
timeCreated: metadata.timeCreated,
snapshotSrc: "data:image/jpg;base64," + snapshotData.snapshotData
};
}
);
showSnapshotViewerDialog.value = true;
})
.catch((error) => {
if (error.response) {
useStateStore().showSnackbarMessage({
color: "error",
message: error.response.data.text || error.response.data
});
} else if (error.request) {
useStateStore().showSnackbarMessage({
color: "error",
message: "Error while trying to process the request! The backend didn't respond."
});
} else {
useStateStore().showSnackbarMessage({
color: "error",
message: "An error occurred while trying to process the request."
});
}
});
};
const showSnapshotViewerDialog = ref(false);
const expanded = ref([]);
</script>
<template>
<v-card dark class="pr-6 pb-3" style="background-color: #006492">
<v-card-title>Camera Control</v-card-title>
<v-row class="pl-6">
<v-col>
<v-btn color="secondary" @click="fetchSnapshots">
<v-icon left> mdi-folder </v-icon>
Show Saved Snapshots
</v-btn>
</v-col>
</v-row>
<v-dialog v-model="showSnapshotViewerDialog">
<v-card dark class="pt-3 pl-5 pr-5" color="primary" flat>
<v-card-title> View Saved Frame Snapshots </v-card-title>
<v-divider />
<v-card-text v-if="imgData.length === 0" style="font-size: 18px; font-weight: 600" class="pt-4">
There are no snapshots saved
</v-card-text>
<div v-else class="pb-2">
<v-data-table
v-model:expanded="expanded"
:headers="[
{ text: 'Snapshot Name', value: 'snapshotShortName', sortable: false },
{ text: 'Camera Unique Name', value: 'cameraUniqueName' },
{ text: 'Camera Nickname', value: 'cameraNickname' },
{ text: 'Stream Type', value: 'streamType' },
{ text: 'Time Created', value: 'timeCreated' },
{ text: 'Actions', value: 'actions', sortable: false }
]"
:items="imgData"
group-by="cameraUniqueName"
class="elevation-0"
item-key="index"
show-expand
expand-icon="mdi-eye"
>
<template #expanded-item="{ headers, item }">
<td :colspan="headers.length">
<div style="display: flex; justify-content: center; width: 100%">
<img :src="item.snapshotSrc" alt="snapshot-image" class="snapshot-preview pt-2 pb-2" />
</div>
</td>
</template>
<!-- eslint-disable-next-line vue/valid-v-slot-->
<template #item.actions="{ item }">
<div style="display: flex; justify-content: center">
<a :download="item.snapshotName" :href="item.snapshotSrc">
<v-icon small> mdi-download </v-icon>
</a>
</div>
</template>
</v-data-table>
<span
>Snapshot Timestamps may be incorrect as they depend on when the coprocessor was last connected to the
internet</span
>
</div>
</v-card>
</v-dialog>
</v-card>
</template>
<style scoped lang="scss">
.v-divider {
border-color: white !important;
}
.v-btn {
width: 100%;
}
.v-data-table {
text-align: center;
background-color: #006492 !important;
th,
td {
background-color: #005281 !important;
font-size: 1rem !important;
}
tbody :hover tr {
background-color: #005281 !important;
}
::-webkit-scrollbar {
width: 0;
height: 0.55em;
border-radius: 5px;
}
::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background-color: #ffd843;
border-radius: 10px;
}
}
.snapshot-preview {
max-width: 55%;
}
@media only screen and (max-width: 512px) {
.snapshot-preview {
max-width: 100%;
}
}
</style>

View File

@@ -5,6 +5,7 @@ import { useCameraSettingsStore } from "@/stores/settings/CameraSettingsStore";
import { computed } from "vue";
import CamerasView from "@/components/cameras/CamerasView.vue";
import { useStateStore } from "@/stores/StateStore";
import CameraControlCard from "@/components/cameras/CameraControlCard.vue";
const cameraViewType = computed<number[]>({
get: (): number[] => {
@@ -40,6 +41,7 @@ const cameraViewType = computed<number[]>({
<v-col cols="12" md="7">
<CamerasCard />
<CalibrationCard />
<CameraControlCard />
</v-col>
<v-col class="pl-md-3 pt-3 pt-md-0" cols="12" md="5">
<CamerasView v-model="cameraViewType" />

View File

@@ -30,4 +30,6 @@ public enum CameraQuirk {
CompletelyBroken,
/** Has adjustable focus and autofocus switch */
AdjustableFocus,
/** Changing FPS repeatedly with small delay does not work correctly */
StickyFPS,
}

View File

@@ -44,7 +44,8 @@ public class QuirkyCamera {
new QuirkyCamera(
-1, -1, "mmal service 16.1", CameraQuirk.PiCam), // PiCam (via V4L2, not zerocopy)
new QuirkyCamera(-1, -1, "unicam", CameraQuirk.PiCam), // PiCam (via V4L2, not zerocopy)
new QuirkyCamera(0x85B, 0x46D, CameraQuirk.AdjustableFocus) // Logitech C925-e
new QuirkyCamera(0x85B, 0x46D, CameraQuirk.AdjustableFocus), // Logitech C925-e
new QuirkyCamera(0x6366, 0x0c45, CameraQuirk.StickyFPS) // Arducam OV2311
);
public static final QuirkyCamera DefaultCamera = new QuirkyCamera(0, 0, "");

View File

@@ -102,7 +102,8 @@ public class USBCameraSource extends VisionSource {
protected USBCameraSettables(CameraConfiguration configuration) {
super(configuration);
getAllVideoModes();
setVideoMode(videoModes.get(0));
if (!cameraQuirks.hasQuirk(CameraQuirk.StickyFPS))
setVideoMode(videoModes.get(0)); // fixes double FPS set
}
public void setAutoExposure(boolean cameraAutoExposure) {

View File

@@ -47,14 +47,16 @@ public class FileSaveFrameConsumer implements Consumer<CVMat> {
private final String ntEntryName;
private IntegerEntry saveFrameEntry;
private final String cameraUniqueName;
private String cameraNickname;
private final String streamType;
private long savedImagesCount = 0;
public FileSaveFrameConsumer(String camNickname, String streamPrefix) {
public FileSaveFrameConsumer(String camNickname, String cameraUniqueName, String streamPrefix) {
this.ntEntryName = streamPrefix + NT_SUFFIX;
this.cameraNickname = camNickname;
this.cameraUniqueName = cameraUniqueName;
this.streamType = streamPrefix;
this.rootTable = NetworkTablesManager.getInstance().kRootTable;
@@ -74,7 +76,15 @@ public class FileSaveFrameConsumer implements Consumer<CVMat> {
String fileName =
cameraNickname + "_" + streamType + "_" + df.format(now) + "T" + tf.format(now);
String saveFilePath = FILE_PATH + File.separator + fileName + FILE_EXTENSION;
// Check if the Unique Camera directory exists and create it if it doesn't
String cameraPath = FILE_PATH + File.separator + this.cameraUniqueName;
var cameraDir = new File(cameraPath);
if (!cameraDir.exists()) {
cameraDir.mkdir();
}
String saveFilePath = cameraPath + File.separator + fileName + FILE_EXTENSION;
Imgcodecs.imwrite(saveFilePath, image.getMat());

View File

@@ -176,10 +176,15 @@ public class VisionModule {
this.outputStreamPort = 1181 + (camStreamIdx * 2) + 1;
inputFrameSaver =
new FileSaveFrameConsumer(visionSource.getSettables().getConfiguration().nickname, "input");
new FileSaveFrameConsumer(
visionSource.getSettables().getConfiguration().nickname,
visionSource.getSettables().getConfiguration().uniqueName,
"input");
outputFrameSaver =
new FileSaveFrameConsumer(
visionSource.getSettables().getConfiguration().nickname, "output");
visionSource.getSettables().getConfiguration().nickname,
visionSource.getSettables().getConfiguration().uniqueName,
"output");
String camHostname = CameraServerJNI.getHostname();
inputVideoStreamer =

View File

@@ -100,7 +100,8 @@ public class PhotonPoseEstimator {
* @param fieldTags A WPILib {@link AprilTagFieldLayout} linking AprilTag IDs to Pose3d objects
* with respect to the FIRST field using the <a href=
* "https://docs.wpilib.org/en/stable/docs/software/advanced-controls/geometry/coordinate-systems.html#field-coordinate-system">Field
* Coordinate System</a>.
* Coordinate System</a>. Note that setting the origin of this layout object will affect the
* results from this class.
* @param strategy The strategy it should use to determine the best pose.
* @param camera PhotonCamera
* @param robotToCamera Transform3d from the center of the robot to the camera mount position (ie,
@@ -141,6 +142,8 @@ public class PhotonPoseEstimator {
/**
* Get the AprilTagFieldLayout being used by the PositionEstimator.
*
* <p>Note: Setting the origin of this layout will affect the results from this class.
*
* @return the AprilTagFieldLayout
*/
public AprilTagFieldLayout getFieldTags() {
@@ -150,6 +153,8 @@ public class PhotonPoseEstimator {
/**
* Set the AprilTagFieldLayout being used by the PositionEstimator.
*
* <p>Note: Setting the origin of this layout will affect the results from this class.
*
* @param fieldTags the AprilTagFieldLayout
*/
public void setFieldTags(AprilTagFieldLayout fieldTags) {
@@ -415,6 +420,7 @@ public class PhotonPoseEstimator {
var best =
new Pose3d()
.plus(best_tf) // field-to-camera
.relativeTo(fieldTags.getOrigin())
.plus(robotToCamera.inverse()); // field-to-robot
return Optional.of(
new EstimatedRobotPose(

View File

@@ -33,13 +33,11 @@ import edu.wpi.first.cscore.VideoSource.ConnectionStrategy;
import edu.wpi.first.math.MathUtil;
import edu.wpi.first.math.Pair;
import edu.wpi.first.math.geometry.Pose3d;
import edu.wpi.first.util.CombinedRuntimeLoader;
import edu.wpi.first.util.WPIUtilJNI;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import org.opencv.core.Core;
import org.opencv.core.CvType;
import org.opencv.core.Mat;
import org.opencv.core.Point;
@@ -95,11 +93,7 @@ public class PhotonCameraSim implements AutoCloseable {
private boolean videoSimProcEnabled = true;
static {
try {
CombinedRuntimeLoader.loadLibraries(OpenCVHelp.class, Core.NATIVE_LIBRARY_NAME, "cscorejni");
} catch (Exception e) {
throw new RuntimeException("Failed to load native libraries!", e);
}
OpenCVHelp.forceLoadOpenCV();
}
@Override

View File

@@ -28,7 +28,6 @@ import edu.wpi.first.cscore.CvSource;
import edu.wpi.first.math.geometry.Pose3d;
import edu.wpi.first.math.geometry.Translation3d;
import edu.wpi.first.math.util.Units;
import edu.wpi.first.util.CombinedRuntimeLoader;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.ArrayList;
@@ -67,11 +66,7 @@ public class VideoSimUtil {
private static double fieldWidth = 8.0137;
static {
try {
CombinedRuntimeLoader.loadLibraries(OpenCVHelp.class, Core.NATIVE_LIBRARY_NAME, "cscorejni");
} catch (Exception e) {
throw new RuntimeException("Failed to load native libraries!", e);
}
OpenCVHelp.forceLoadOpenCV();
// create Mats of 8x8 apriltag images
for (int i = 0; i < VideoSimUtil.kNumTags16h5; i++) {

View File

@@ -53,8 +53,6 @@ public class DataSocketHandler {
@SuppressWarnings("FieldCanBeLocal")
private final UIOutboundSubscriber uiOutboundSubscriber = new UIOutboundSubscriber(this);
public static class UIMap extends HashMap<String, Object> {}
private static class ThreadSafeSingleton {
private static final DataSocketHandler INSTANCE = new DataSocketHandler();
}
@@ -70,23 +68,23 @@ public class DataSocketHandler {
}
public void onConnect(WsConnectContext context) {
users.add(context);
context.session.setIdleTimeout(
Duration.ofMillis(Long.MAX_VALUE)); // TODO: determine better value
var remote = (InetSocketAddress) context.session.getRemoteAddress();
var host = remote.getAddress().toString() + ":" + remote.getPort();
logger.info("New websocket connection from " + host);
users.add(context);
dcService.publishEvent(
new IncomingWebSocketEvent<>(
DataChangeDestination.DCD_GENSETTINGS, "userConnected", context));
}
protected void onClose(WsCloseContext context) {
users.remove(context);
var remote = (InetSocketAddress) context.session.getRemoteAddress();
var host = remote.getAddress().toString() + ":" + remote.getPort();
var reason = context.reason() != null ? context.reason() : "Connection closed by client";
logger.info("Closing websocket connection from " + host + " for reason: " + reason);
users.remove(context);
}
@SuppressWarnings({"unchecked"})
@@ -349,7 +347,9 @@ public class DataSocketHandler {
private void sendMessage(Object message, WsContext user) throws JsonProcessingException {
ByteBuffer b = ByteBuffer.wrap(objectMapper.writeValueAsBytes(message));
user.send(b);
if (user.session.isOpen()) {
user.send(b);
}
}
public void broadcastMessage(Object message, WsContext userToSkip)

View File

@@ -21,15 +21,14 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.javalin.http.Context;
import io.javalin.http.UploadedFile;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Optional;
import javax.imageio.ImageIO;
import org.apache.commons.io.FileUtils;
import org.photonvision.common.configuration.ConfigManager;
import org.photonvision.common.configuration.NetworkConfig;
@@ -526,6 +525,43 @@ public class RequestHandler {
ctx.status(204);
}
public static void onImageSnapshotsRequest(Context ctx) {
var snapshots = new ArrayList<HashMap<String, Object>>();
var cameraDirs = ConfigManager.getInstance().getImageSavePath().toFile().listFiles();
if (cameraDirs != null) {
try {
for (File cameraDir : cameraDirs) {
var cameraSnapshots = cameraDir.listFiles();
if (cameraSnapshots == null) continue;
String cameraUniqueName = cameraDir.getName();
for (File snapshot : cameraSnapshots) {
var snapshotData = new HashMap<String, Object>();
var bufferedImage = ImageIO.read(snapshot);
var buffer = new ByteArrayOutputStream();
ImageIO.write(bufferedImage, "jpg", buffer);
byte[] data = buffer.toByteArray();
snapshotData.put("snapshotName", snapshot.getName());
snapshotData.put("cameraUniqueName", cameraUniqueName);
snapshotData.put("snapshotData", data);
snapshots.add(snapshotData);
}
}
} catch (IOException e) {
ctx.status(500);
ctx.result("Unable to read saved images");
}
}
ctx.status(200);
ctx.json(snapshots);
}
/**
* Create a temporary file using the UploadedFile from Javalin.
*

View File

@@ -97,6 +97,7 @@ public class Server {
app.post("/api/utils/restartProgram", RequestHandler::onProgramRestartRequest);
app.post("/api/utils/restartDevice", RequestHandler::onDeviceRestartRequest);
app.post("/api/utils/publishMetrics", RequestHandler::onMetricsPublishRequest);
app.get("/api/utils/getImageSnapshots", RequestHandler::onImageSnapshotsRequest);
// Calibration
app.post("/api/calibration/end", RequestHandler::onCalibrationEndRequest);

View File

@@ -8,6 +8,7 @@ apply from: "${rootDir}/shared/common.gradle"
dependencies {
implementation wpilibTools.deps.wpilibJava("wpimath")
implementation wpilibTools.deps.wpilibJava("apriltag")
implementation wpilibTools.deps.wpilibJava("cscore")
implementation wpilibTools.deps.wpilibOpenCvJava("frc" + wpi.frcYear.get(), wpi.versions.opencvVersion.get())
implementation group: "org.ejml", name: "ejml-simple", version: wpi.versions.ejmlVersion.get()

View File

@@ -17,6 +17,7 @@
package org.photonvision.estimation;
import edu.wpi.first.cscore.CvSink;
import edu.wpi.first.math.Matrix;
import edu.wpi.first.math.Nat;
import edu.wpi.first.math.Num;
@@ -26,7 +27,6 @@ import edu.wpi.first.math.geometry.Rotation3d;
import edu.wpi.first.math.geometry.Transform3d;
import edu.wpi.first.math.geometry.Translation3d;
import edu.wpi.first.math.numbers.*;
import edu.wpi.first.util.CombinedRuntimeLoader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -52,13 +52,17 @@ public final class OpenCVHelp {
private static Rotation3d NWU_TO_EDN;
private static Rotation3d EDN_TO_NWU;
static {
try {
CombinedRuntimeLoader.loadLibraries(OpenCVHelp.class, Core.NATIVE_LIBRARY_NAME, "cscorejni");
} catch (Exception e) {
throw new RuntimeException("Failed to load native libraries!", e);
}
// Creating a cscore object is sufficient to load opencv, per
// https://www.chiefdelphi.com/t/unsatisfied-link-error-when-simulating-java-robot-code-using-opencv/426731/4
private static CvSink dummySink = null;
public static void forceLoadOpenCV() {
if (dummySink != null) return;
dummySink = new CvSink("ignored");
dummySink.close();
}
static {
NWU_TO_EDN = new Rotation3d(Matrix.mat(Nat.N3(), Nat.N3()).fill(0, -1, 0, 0, 0, -1, 1, 0, 0));
EDN_TO_NWU = new Rotation3d(Matrix.mat(Nat.N3(), Nat.N3()).fill(0, 0, 1, -1, 0, 0, 0, -1, 0));
}

View File

@@ -1,6 +1,6 @@
plugins {
id "com.diffplug.spotless" version "6.1.2"
id "edu.wpi.first.GradleRIO" version "2024.1.1-beta-2" apply false
id "edu.wpi.first.GradleRIO" version "2024.1.1-beta-3" apply false
}
allprojects {

View File

@@ -1,6 +1,6 @@
plugins {
id "com.diffplug.spotless" version "6.1.2"
id "edu.wpi.first.GradleRIO" version "2024.1.1-beta-2" apply false
id "edu.wpi.first.GradleRIO" version "2024.1.1-beta-3" apply false
}
apply from: "examples.gradle"