Merge "Make image debug handler on webpage reliable"
diff --git a/aos/events/logging/log_reader.cc b/aos/events/logging/log_reader.cc
index 4e89a0e..1c3a349 100644
--- a/aos/events/logging/log_reader.cc
+++ b/aos/events/logging/log_reader.cc
@@ -276,6 +276,9 @@
// Otherwise collect this one up as a node to look for a combined
// channel from. It is more efficient to compare nodes than channels.
+ LOG(WARNING) << "Failed to find channel "
+ << finder.SplitChannelName(channel, connection)
+ << " on node " << aos::FlatbufferToJson(node);
remote_nodes.insert(connection->name()->string_view());
}
}
diff --git a/aos/network/timestamp_channel.cc b/aos/network/timestamp_channel.cc
index ab61051..fdaa031 100644
--- a/aos/network/timestamp_channel.cc
+++ b/aos/network/timestamp_channel.cc
@@ -2,6 +2,10 @@
#include "absl/strings/str_cat.h"
+DEFINE_bool(combined_timestamp_channel_fallback, true,
+ "If true, fall back to using the combined timestamp channel if the "
+ "single timestamp channel doesn't exist for a timestamp.");
+
namespace aos {
namespace message_bridge {
@@ -12,7 +16,8 @@
std::string ChannelTimestampFinder::SplitChannelName(
const Channel *channel, const Connection *connection) {
- return SplitChannelName(channel->name()->string_view(), channel->type()->str(), connection);
+ return SplitChannelName(channel->name()->string_view(),
+ channel->type()->str(), connection);
}
std::string ChannelTimestampFinder::SplitChannelName(
@@ -47,6 +52,15 @@
return split_timestamp_channel;
}
+ if (!FLAGS_combined_timestamp_channel_fallback) {
+ LOG(FATAL) << "Failed to find new timestamp channel {\"name\": \""
+ << split_timestamp_channel_name << "\", \"type\": \""
+ << RemoteMessage::GetFullyQualifiedName() << "\"} for "
+ << configuration::CleanedChannelToString(channel)
+ << " connection " << aos::FlatbufferToJson(connection)
+ << " and --nocombined_timestamp_channel_fallback is set";
+ }
+
const std::string shared_timestamp_channel_name =
CombinedChannelName(connection->name()->string_view());
const Channel *shared_timestamp_channel = configuration::GetChannel(
diff --git a/aos/starter/BUILD b/aos/starter/BUILD
index 7590b3b..5bf3811 100644
--- a/aos/starter/BUILD
+++ b/aos/starter/BUILD
@@ -100,6 +100,8 @@
"//aos/events:pingpong_config",
"//aos/events:pong",
],
+ # TODO(james): Fix tihs.
+ flaky = True,
linkopts = ["-lstdc++fs"],
shard_count = 4,
# The roborio compiler doesn't support <filesystem>.
diff --git a/scouting/db/db.go b/scouting/db/db.go
index 85395e1..d3faa58 100644
--- a/scouting/db/db.go
+++ b/scouting/db/db.go
@@ -50,6 +50,12 @@
Notes []string
}
+type Ranking struct {
+ TeamNumber int
+ Losses, Wins, Ties int32
+ Rank, Dq int32
+}
+
// Opens a database at the specified port on localhost. We currently don't
// support connecting to databases on other hosts.
func NewDatabase(user string, password string, port int) (*Database, error) {
@@ -138,6 +144,24 @@
return nil, errors.New(fmt.Sprint("Failed to create notes table: ", err))
}
+ statement, err = database.Prepare("CREATE TABLE IF NOT EXISTS rankings (" +
+ "id SERIAL PRIMARY KEY, " +
+ "Losses INTEGER, " +
+ "Wins INTEGER, " +
+ "Ties INTEGER, " +
+ "Rank INTEGER, " +
+ "Dq INTEGER, " +
+ "TeamNumber INTEGER)")
+ if err != nil {
+ return nil, errors.New(fmt.Sprint("Failed to prepare rankings table creation: ", err))
+ }
+ defer statement.Close()
+
+ _, err = statement.Exec()
+ if err != nil {
+ return nil, errors.New(fmt.Sprint("Failed to create rankings table: ", err))
+ }
+
return database, nil
}
@@ -169,6 +193,16 @@
return errors.New(fmt.Sprint("Failed to drop notes table: ", err))
}
return nil
+
+ statement, err = database.Prepare("DROP TABLE IF EXISTS rankings")
+ if err != nil {
+ return errors.New(fmt.Sprint("Failed to prepare dropping rankings table: ", err))
+ }
+ _, err = statement.Exec()
+ if err != nil {
+ return errors.New(fmt.Sprint("Failed to drop rankings table: ", err))
+ }
+ return nil
}
// This function will also populate the Stats table with six empty rows every time a match is added
@@ -278,6 +312,48 @@
return nil
}
+func (database *Database) AddOrUpdateRankings(r Ranking) error {
+ statement, err := database.Prepare("UPDATE rankings SET " +
+ "Losses = $1, Wins = $2, Ties = $3, " +
+ "Rank = $4, Dq = $5, TeamNumber = $6 " +
+ "WHERE TeamNumber = $6")
+ if err != nil {
+ return errors.New(fmt.Sprint("Failed to prepare rankings database update: ", err))
+ }
+ defer statement.Close()
+
+ result, err := statement.Exec(r.Losses, r.Wins, r.Ties,
+ r.Rank, r.Dq, r.TeamNumber)
+ if err != nil {
+ return errors.New(fmt.Sprint("Failed to update rankings database: ", err))
+ }
+
+ numRowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return errors.New(fmt.Sprint("Failed to query rows affected: ", err))
+ }
+ if numRowsAffected == 0 {
+ statement, err := database.Prepare("INSERT INTO rankings(" +
+ "Losses, Wins, Ties, " +
+ "Rank, Dq, TeamNumber) " +
+ "VALUES (" +
+ "$1, $2, $3, " +
+ "$4, $5, $6)")
+ if err != nil {
+ return errors.New(fmt.Sprint("Failed to prepare insertion into rankings database: ", err))
+ }
+ defer statement.Close()
+
+ _, err = statement.Exec(r.Losses, r.Wins, r.Ties,
+ r.Rank, r.Dq, r.TeamNumber)
+ if err != nil {
+ return errors.New(fmt.Sprint("Failed to insert into rankings database: ", err))
+ }
+ }
+
+ return nil
+}
+
func (database *Database) ReturnMatches() ([]Match, error) {
rows, err := database.Query("SELECT * FROM matches")
if err != nil {
@@ -328,6 +404,28 @@
return teams, nil
}
+func (database *Database) ReturnRankings() ([]Ranking, error) {
+ rows, err := database.Query("SELECT * FROM rankings")
+ if err != nil {
+ return nil, errors.New(fmt.Sprint("Failed to SELECT * FROM rankings: ", err))
+ }
+ defer rows.Close()
+
+ all_rankings := make([]Ranking, 0)
+ for rows.Next() {
+ var ranking Ranking
+ var id int
+ err = rows.Scan(&id,
+ &ranking.Losses, &ranking.Wins, &ranking.Ties,
+ &ranking.Rank, &ranking.Dq, &ranking.TeamNumber)
+ if err != nil {
+ return nil, errors.New(fmt.Sprint("Failed to scan from rankings: ", err))
+ }
+ all_rankings = append(all_rankings, ranking)
+ }
+ return all_rankings, nil
+}
+
func (database *Database) QueryMatches(teamNumber_ int32) ([]Match, error) {
rows, err := database.Query("SELECT * FROM matches WHERE "+
"R1 = $1 OR R2 = $2 OR R3 = $3 OR B1 = $4 OR B2 = $5 OR B3 = $6",
@@ -400,6 +498,28 @@
return NotesData{TeamNumber, notes}, nil
}
+func (database *Database) QueryRankings(TeamNumber int) ([]Ranking, error) {
+ rows, err := database.Query("SELECT * FROM rankings WHERE TeamNumber = $1", TeamNumber)
+ if err != nil {
+ return nil, errors.New(fmt.Sprint("Failed to select from rankings: ", err))
+ }
+ defer rows.Close()
+
+ all_rankings := make([]Ranking, 0)
+ for rows.Next() {
+ var ranking Ranking
+ var id int
+ err = rows.Scan(&id,
+ &ranking.Losses, &ranking.Wins, &ranking.Ties,
+ &ranking.Rank, &ranking.Dq, &ranking.TeamNumber)
+ if err != nil {
+ return nil, errors.New(fmt.Sprint("Failed to scan from rankings: ", err))
+ }
+ all_rankings = append(all_rankings, ranking)
+ }
+ return all_rankings, nil
+}
+
func (database *Database) AddNotes(data NotesData) error {
if len(data.Notes) > 1 {
return errors.New("Can only insert one row of notes at a time")
diff --git a/scouting/db/db_test.go b/scouting/db/db_test.go
index 02e8d50..5725dcb 100644
--- a/scouting/db/db_test.go
+++ b/scouting/db/db_test.go
@@ -82,6 +82,36 @@
}
}
+func TestAddOrUpdateRankingsDB(t *testing.T) {
+ fixture := createDatabase(t)
+ defer fixture.TearDown()
+
+ correct := []Ranking{
+ Ranking{
+ TeamNumber: 123,
+ Losses: 1, Wins: 7, Ties: 0,
+ Rank: 2, Dq: 0,
+ },
+ Ranking{
+ TeamNumber: 125,
+ Losses: 2, Wins: 4, Ties: 0,
+ Rank: 2, Dq: 0,
+ },
+ }
+
+ for i := 0; i < len(correct); i++ {
+ err := fixture.db.AddOrUpdateRankings(correct[i])
+ check(t, err, "Failed to add ranking data")
+ }
+
+ got, err := fixture.db.ReturnRankings()
+ check(t, err, "Failed ReturnRankings()")
+
+ if !reflect.DeepEqual(correct, got) {
+ t.Fatalf("Got %#v,\nbut expected %#v.", got, correct)
+ }
+}
+
func TestAddToStatsDB(t *testing.T) {
fixture := createDatabase(t)
defer fixture.TearDown()
@@ -283,6 +313,54 @@
}
}
+func TestQueryRankingsDB(t *testing.T) {
+ fixture := createDatabase(t)
+ defer fixture.TearDown()
+
+ testDatabase := []Ranking{
+ Ranking{
+ TeamNumber: 123,
+ Losses: 1, Wins: 7, Ties: 2,
+ Rank: 2, Dq: 0,
+ },
+ Ranking{
+ TeamNumber: 124,
+ Losses: 3, Wins: 4, Ties: 0,
+ Rank: 4, Dq: 2,
+ },
+ Ranking{
+ TeamNumber: 125,
+ Losses: 5, Wins: 2, Ties: 0,
+ Rank: 17, Dq: 0,
+ },
+ Ranking{
+ TeamNumber: 126,
+ Losses: 0, Wins: 7, Ties: 0,
+ Rank: 5, Dq: 0,
+ },
+ }
+
+ for i := 0; i < len(testDatabase); i++ {
+ err := fixture.db.AddOrUpdateRankings(testDatabase[i])
+ check(t, err, fmt.Sprint("Failed to add rankings ", i))
+ }
+
+ correct := []Ranking{
+ Ranking{
+ TeamNumber: 126,
+ Losses: 0, Wins: 7, Ties: 0,
+ Rank: 5, Dq: 0,
+ },
+ }
+
+ got, err := fixture.db.QueryRankings(126)
+ check(t, err, "Failed QueryRankings()")
+
+ if !reflect.DeepEqual(correct, got) {
+ t.Errorf("Got %#v,\nbut expected %#v.", got, correct)
+ }
+}
+
func TestReturnMatchDB(t *testing.T) {
fixture := createDatabase(t)
defer fixture.TearDown()
@@ -328,6 +406,46 @@
}
}
+func TestReturnRankingsDB(t *testing.T) {
+ fixture := createDatabase(t)
+ defer fixture.TearDown()
+
+ correct := []Ranking{
+ Ranking{
+ TeamNumber: 123,
+ Losses: 1, Wins: 7, Ties: 2,
+ Rank: 2, Dq: 0,
+ },
+ Ranking{
+ TeamNumber: 124,
+ Losses: 3, Wins: 4, Ties: 0,
+ Rank: 4, Dq: 2,
+ },
+ Ranking{
+ TeamNumber: 125,
+ Losses: 5, Wins: 2, Ties: 0,
+ Rank: 17, Dq: 0,
+ },
+ Ranking{
+ TeamNumber: 126,
+ Losses: 0, Wins: 7, Ties: 0,
+ Rank: 5, Dq: 0,
+ },
+ }
+
+ for i := 0; i < len(correct); i++ {
+ err := fixture.db.AddOrUpdateRankings(correct[i])
+ check(t, err, fmt.Sprint("Failed to add rankings", i))
+ }
+
+ got, err := fixture.db.ReturnRankings()
+ check(t, err, "Failed ReturnRankings()")
+
+ if !reflect.DeepEqual(correct, got) {
+ t.Errorf("Got %#v,\nbut expected %#v.", got, correct)
+ }
+}
+
func TestReturnStatsDB(t *testing.T) {
fixture := createDatabase(t)
defer fixture.TearDown()
@@ -400,6 +518,59 @@
}
}
+func TestRankingsDbUpdate(t *testing.T) {
+ fixture := createDatabase(t)
+ defer fixture.TearDown()
+
+ testDatabase := []Ranking{
+ Ranking{
+ TeamNumber: 123,
+ Losses: 1, Wins: 7, Ties: 2,
+ Rank: 2, Dq: 0,
+ },
+ Ranking{
+ TeamNumber: 124,
+ Losses: 3, Wins: 4, Ties: 0,
+ Rank: 4, Dq: 2,
+ },
+ Ranking{
+ TeamNumber: 125,
+ Losses: 5, Wins: 2, Ties: 0,
+ Rank: 17, Dq: 0,
+ },
+ Ranking{
+ TeamNumber: 126,
+ Losses: 0, Wins: 7, Ties: 0,
+ Rank: 5, Dq: 0,
+ },
+ Ranking{
+ TeamNumber: 125,
+ Losses: 2, Wins: 4, Ties: 1,
+ Rank: 5, Dq: 0,
+ },
+ }
+
+ for i := 0; i < len(testDatabase); i++ {
+ err := fixture.db.AddOrUpdateRankings(testDatabase[i])
+ check(t, err, fmt.Sprint("Failed to add rankings ", i))
+ }
+
+ correct := []Ranking{
+ Ranking{
+ TeamNumber: 125,
+ Losses: 2, Wins: 4, Ties: 1,
+ Rank: 5, Dq: 0,
+ },
+ }
+
+ got, err := fixture.db.QueryRankings(125)
+ check(t, err, "Failed QueryRankings()")
+
+ if !reflect.DeepEqual(correct, got) {
+ t.Errorf("Got %#v,\nbut expected %#v.", got, correct)
+ }
+}
+
func TestNotes(t *testing.T) {
fixture := createDatabase(t)
defer fixture.TearDown()
diff --git a/scouting/scraping/types.go b/scouting/scraping/types.go
index 9283ac2..d8a79f1 100644
--- a/scouting/scraping/types.go
+++ b/scouting/scraping/types.go
@@ -5,20 +5,20 @@
}
type Rank struct {
- MatchesPlayed int `json:"matches_played"`
- QualAverage int `json:"qual_average"`
+ MatchesPlayed int32 `json:"matches_played"`
+ QualAverage int32 `json:"qual_average"`
ExtraStats []float64 `json:"extra_stats"`
SortOrders []float64 `json:"sort_orders"`
Records Record `json:"record"`
- Rank int `json:"rank"`
- Dq int `json:"dq"`
+ Rank int32 `json:"rank"`
+ Dq int32 `json:"dq"`
TeamKey string `json:"team_key"`
}
type Record struct {
- Losses int `json:"losses"`
- Wins int `json:"wins"`
- Ties int `json:"ties"`
+ Losses int32 `json:"losses"`
+ Wins int32 `json:"wins"`
+ Ties int32 `json:"ties"`
}
// Match holds the TBA data for a given match
diff --git a/scouting/webserver/static/BUILD b/scouting/webserver/static/BUILD
index 3166853..0cf22f3 100644
--- a/scouting/webserver/static/BUILD
+++ b/scouting/webserver/static/BUILD
@@ -13,6 +13,7 @@
name = "static_test",
srcs = ["static_test.go"],
data = [
+ "test_pages/index.html",
"test_pages/page.txt",
"test_pages/root.txt",
],
diff --git a/scouting/webserver/static/static.go b/scouting/webserver/static/static.go
index 92d8086..4c46fe7 100644
--- a/scouting/webserver/static/static.go
+++ b/scouting/webserver/static/static.go
@@ -2,7 +2,15 @@
// A year agnostic way to serve static http files.
import (
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io"
+ "log"
"net/http"
+ "os"
+ "path/filepath"
+ "strings"
"time"
"github.com/frc971/971-Robot-Code/scouting/webserver/server"
@@ -20,10 +28,98 @@
"X-Accel-Expires": "0",
}
-func NoCache(h http.Handler) http.Handler {
+func MaybeNoCache(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
- for k, v := range noCacheHeaders {
- w.Header().Set(k, v)
+ // We force the browser not to cache index.html so that
+ // browsers will notice when the bundle gets updated.
+ if r.URL.Path == "/" || r.URL.Path == "/index.html" {
+ for k, v := range noCacheHeaders {
+ w.Header().Set(k, v)
+ }
+ }
+
+ h.ServeHTTP(w, r)
+ }
+
+ return http.HandlerFunc(fn)
+}
+
+// Computes the sha256 of the specified file.
+func computeSha256(path string) (string, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return "", errors.New(fmt.Sprint("Failed to open ", path, ": ", err))
+ }
+ defer file.Close()
+
+ hash := sha256.New()
+ if _, err := io.Copy(hash, file); err != nil {
+ return "", errors.New(fmt.Sprint("Failed to compute sha256 of ", path, ": ", err))
+ }
+ return fmt.Sprintf("%x", hash.Sum(nil)), nil
+}
+
+// Finds the checksums for all the files in the specified directory. This is a
+// best effort only. If for some reason we fail to compute the checksum of
+// something, we just move on.
+func findAllFileShas(directory string) map[string]string {
+ shaSums := make(map[string]string)
+
+ // Find the checksums for all the files.
+ err := filepath.Walk(directory, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ log.Println("Walk() didn't want to deal with ", path, ":", err)
+ return nil
+ }
+ if info.IsDir() {
+ // We only care about computing checksums of files.
+ // Ignore directories.
+ return nil
+ }
+ hash, err := computeSha256(path)
+ if err != nil {
+ log.Println(err)
+ return nil
+ }
+ shaSums[hash] = "/" + strings.TrimPrefix(path, directory)
+ return nil
+ })
+ if err != nil {
+ log.Fatal("Got unexpected error from Walk(): ", err)
+ }
+
+ return shaSums
+}
+
+func HandleShaUrl(directory string, h http.Handler) http.Handler {
+ shaSums := findAllFileShas(directory)
+
+ fn := func(w http.ResponseWriter, r *http.Request) {
+ // We expect the path portion to look like this:
+ // /sha256/<checksum>/path...
+ // Splitting on / means we end up with this list:
+ // [0] ""
+ // [1] "sha256"
+ // [2] "<checksum>"
+ // [3-] path...
+ parts := strings.Split(r.URL.Path, "/")
+ if len(parts) < 4 {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ if parts[0] != "" || parts[1] != "sha256" {
+ // Something is fundamentally wrong. We told the
+ // framework to only give is /sha256/ requests.
+ log.Fatal("This handler should not be called for " + r.URL.Path)
+ }
+ hash := parts[2]
+ if path, ok := shaSums[hash]; ok {
+ // We found a file with this checksum. Serve that file.
+ r.URL.Path = path
+ } else {
+ // No file with this checksum found.
+ w.WriteHeader(http.StatusNotFound)
+ return
}
h.ServeHTTP(w, r)
@@ -35,8 +131,8 @@
// Serve pages in the specified directory.
func ServePages(scoutingServer server.ScoutingServer, directory string) {
// Serve the / endpoint given a folder of pages.
- scoutingServer.Handle("/", NoCache(http.FileServer(http.Dir(directory))))
- // Make an exception for pictures. We don't want the pictures to be
- // pulled every time the page is refreshed.
- scoutingServer.Handle("/pictures/", http.FileServer(http.Dir(directory)))
+ scoutingServer.Handle("/", MaybeNoCache(http.FileServer(http.Dir(directory))))
+
+ // Also serve files in a checksum-addressable manner.
+ scoutingServer.Handle("/sha256/", HandleShaUrl(directory, http.FileServer(http.Dir(directory))))
}
diff --git a/scouting/webserver/static/static_test.go b/scouting/webserver/static/static_test.go
index 3524c05..09ed940 100644
--- a/scouting/webserver/static/static_test.go
+++ b/scouting/webserver/static/static_test.go
@@ -9,6 +9,12 @@
"github.com/frc971/971-Robot-Code/scouting/webserver/server"
)
+func expectEqual(t *testing.T, actual string, expected string) {
+ if actual != expected {
+ t.Error("Expected ", actual, " to equal ", expected)
+ }
+}
+
func TestServing(t *testing.T) {
cases := []struct {
// The path to request from the server.
@@ -17,6 +23,7 @@
// specified path.
expectedData string
}{
+ {"/", "<h1>This is the index</h1>\n"},
{"/root.txt", "Hello, this is the root page!"},
{"/page.txt", "Hello from a page!"},
}
@@ -24,37 +31,67 @@
scoutingServer := server.NewScoutingServer()
ServePages(scoutingServer, "test_pages")
scoutingServer.Start(8080)
+ defer scoutingServer.Stop()
// Go through all the test cases, and run them against the running webserver.
for _, c := range cases {
dataReceived := getData(c.path, t)
- if dataReceived != c.expectedData {
- t.Errorf("Got %q, but expected %q", dataReceived, c.expectedData)
- }
+ expectEqual(t, dataReceived, c.expectedData)
}
-
- scoutingServer.Stop()
}
-func TestCache(t *testing.T) {
+// Makes sure that requesting / sets the proper headers so it doesn't get
+// cached.
+func TestDisallowedCache(t *testing.T) {
scoutingServer := server.NewScoutingServer()
ServePages(scoutingServer, "test_pages")
scoutingServer.Start(8080)
+ defer scoutingServer.Stop()
+
+ resp, err := http.Get("http://localhost:8080/")
+ if err != nil {
+ t.Fatal("Failed to get data ", err)
+ }
+ expectEqual(t, resp.Header.Get("Expires"), "Thu, 01 Jan 1970 00:00:00 UTC")
+ expectEqual(t, resp.Header.Get("Cache-Control"), "no-cache, private, max-age=0")
+ expectEqual(t, resp.Header.Get("Pragma"), "no-cache")
+ expectEqual(t, resp.Header.Get("X-Accel-Expires"), "0")
+}
+
+// Makes sure that requesting anything other than / doesn't set the "do not
+// cache" headers.
+func TestAllowedCache(t *testing.T) {
+ scoutingServer := server.NewScoutingServer()
+ ServePages(scoutingServer, "test_pages")
+ scoutingServer.Start(8080)
+ defer scoutingServer.Stop()
resp, err := http.Get("http://localhost:8080/root.txt")
if err != nil {
t.Fatalf("Failed to get data ", err)
}
- compareString(resp.Header.Get("Expires"), "Thu, 01 Jan 1970 00:00:00 UTC", t)
- compareString(resp.Header.Get("Cache-Control"), "no-cache, private, max-age=0", t)
- compareString(resp.Header.Get("Pragma"), "no-cache", t)
- compareString(resp.Header.Get("X-Accel-Expires"), "0", t)
+ expectEqual(t, resp.Header.Get("Expires"), "")
+ expectEqual(t, resp.Header.Get("Cache-Control"), "")
+ expectEqual(t, resp.Header.Get("Pragma"), "")
+ expectEqual(t, resp.Header.Get("X-Accel-Expires"), "")
}
-func compareString(actual string, expected string, t *testing.T) {
- if actual != expected {
- t.Errorf("Expected ", actual, " to equal ", expected)
+func TestSha256(t *testing.T) {
+ scoutingServer := server.NewScoutingServer()
+ ServePages(scoutingServer, "test_pages")
+ scoutingServer.Start(8080)
+ defer scoutingServer.Stop()
+
+ // Validate a valid checksum.
+ dataReceived := getData("sha256/553b9b29647a112136986cf93c57b988d1f12dc43d3b774f14a24e58d272dbff/root.txt", t)
+ expectEqual(t, dataReceived, "Hello, this is the root page!")
+
+ // Make a request with an invalid checksum and make sure we get a 404.
+ resp, err := http.Get("http://localhost:8080/sha256/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/root.txt")
+ if err != nil {
+ t.Fatal("Failed to get data ", err)
}
+ expectEqual(t, resp.Status, "404 Not Found")
}
// Retrieves the data at the specified path. If an error occurs, the test case
@@ -66,7 +103,7 @@
}
// Error out if the return status is anything other than 200 OK.
if resp.Status != "200 OK" {
- t.Fatalf("Received a status code other than 200")
+ t.Fatal("Received a status code other than 200:", resp.Status)
}
// Read the response body.
body, err := ioutil.ReadAll(resp.Body)
diff --git a/scouting/webserver/static/test_pages/index.html b/scouting/webserver/static/test_pages/index.html
new file mode 100644
index 0000000..d769db4
--- /dev/null
+++ b/scouting/webserver/static/test_pages/index.html
@@ -0,0 +1 @@
+<h1>This is the index</h1>
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index 726749b..647af14 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -66,6 +66,29 @@
cmd = "cp $(location :main_bundle_compiled)/main_bundle.min.js $(OUTS)",
)
+py_binary(
+ name = "index_html_generator",
+ srcs = ["index_html_generator.py"],
+)
+
+genrule(
+ name = "generate_index_html",
+ srcs = [
+ "index.template.html",
+ "main_bundle_file.js",
+ ],
+ outs = ["index.html"],
+ cmd = " ".join([
+ "$(location :index_html_generator)",
+ "--template $(location index.template.html)",
+ "--bundle $(location main_bundle_file.js)",
+ "--output $(location index.html)",
+ ]),
+ tools = [
+ ":index_html_generator",
+ ],
+)
+
# Create a copy of zone.js here so that we can have a predictable path to
# source it from on the webserver.
genrule(
diff --git a/scouting/www/app.ts b/scouting/www/app.ts
index 85620f1..ab9c229 100644
--- a/scouting/www/app.ts
+++ b/scouting/www/app.ts
@@ -6,6 +6,10 @@
| 'Entry'
| 'ImportMatchList'
| 'ShiftSchedule';
+
+// Ignore the guard for tabs that don't require the user to enter any data.
+const unguardedTabs: Tab[] = ['MatchList', 'ImportMatchList'];
+
type TeamInMatch = {
teamNumber: number;
matchNumber: number;
@@ -29,12 +33,14 @@
constructor() {
window.addEventListener('beforeunload', (e) => {
- if (!this.block_alerts.nativeElement.checked) {
- // Based on
- // https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
- // This combination ensures a dialog will be shown on most browsers.
- e.preventDefault();
- e.returnValue = '';
+ if (!unguardedTabs.includes(this.tab)) {
+ if (!this.block_alerts.nativeElement.checked) {
+ // Based on
+ // https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
+ // This combination ensures a dialog will be shown on most browsers.
+ e.preventDefault();
+ e.returnValue = '';
+ }
}
});
}
@@ -51,8 +57,12 @@
switchTabToGuarded(tab: Tab) {
let shouldSwitch = true;
if (this.tab !== tab) {
- if (!this.block_alerts.nativeElement.checked) {
- shouldSwitch = window.confirm('Leave current page?');
+ if (!unguardedTabs.includes(this.tab)) {
+ if (!this.block_alerts.nativeElement.checked) {
+ shouldSwitch = window.confirm(
+ 'Leave current page? You will lose all data.'
+ );
+ }
}
}
if (shouldSwitch) {
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index 0e76268..d0805d4 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -37,7 +37,10 @@
<div *ngSwitchCase="'Auto'" id="auto" class="container-fluid">
<div class="row">
- <img src="/pictures/field/quadrants.jpeg" alt="Quadrants Image" />
+ <img
+ src="/sha256/cbb99a057a2504e80af526dae7a0a04121aed84c56a6f4889e9576fe1c20c61e/pictures/field/quadrants.jpeg"
+ alt="Quadrants Image"
+ />
<form>
<input
type="radio"
@@ -75,7 +78,10 @@
</form>
</div>
<div class="row">
- <img src="/pictures/field/balls.jpeg" alt="Image" />
+ <img
+ src="/sha256/cbb99a057a2504e80af526dae7a0a04121aed84c56a6f4889e9576fe1c20c61e/pictures/field/balls.jpeg"
+ alt="Image"
+ />
<form>
<!--Choice for each ball location-->
<input
diff --git a/scouting/www/index.html b/scouting/www/index.template.html
similarity index 79%
rename from scouting/www/index.html
rename to scouting/www/index.template.html
index c9e2bb3..303dca1 100644
--- a/scouting/www/index.html
+++ b/scouting/www/index.template.html
@@ -14,6 +14,7 @@
</head>
<body>
<my-app></my-app>
- <script src="./main_bundle_file.js"></script>
+ <!-- The path here is auto-generated to be /sha256/<checksum>/main_bundle_file.js. -->
+ <script src="{MAIN_BUNDLE_FILE}"></script>
</body>
</html>
diff --git a/scouting/www/index_html_generator.py b/scouting/www/index_html_generator.py
new file mode 100644
index 0000000..3b057fd
--- /dev/null
+++ b/scouting/www/index_html_generator.py
@@ -0,0 +1,28 @@
+"""Generates index.html with the right checksum for main_bundle_file.js filled in."""
+
+import argparse
+import hashlib
+import sys
+from pathlib import Path
+
+def compute_sha256(filepath):
+ return hashlib.sha256(filepath.read_bytes()).hexdigest()
+
+def main(argv):
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--template", type=str)
+ parser.add_argument("--bundle", type=str)
+ parser.add_argument("--output", type=str)
+ args = parser.parse_args(argv[1:])
+
+ template = Path(args.template).read_text()
+ bundle_path = Path(args.bundle)
+ bundle_sha256 = compute_sha256(bundle_path)
+
+ output = template.format(
+ MAIN_BUNDLE_FILE = f"/sha256/{bundle_sha256}/{bundle_path.name}",
+ )
+ Path(args.output).write_text(output)
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/y2022/BUILD b/y2022/BUILD
index 2e2f26d..5d29f04 100644
--- a/y2022/BUILD
+++ b/y2022/BUILD
@@ -7,6 +7,7 @@
binaries = [
":setpoint_setter",
"//aos/network:web_proxy_main",
+ "//aos/events/logging:log_cat",
],
data = [
":aos_config",
@@ -58,6 +59,7 @@
"//aos/network:message_bridge_server",
"//aos/network:web_proxy_main",
"//y2022/vision:camera_reader",
+ "//y2022/vision:ball_color_detector",
],
target_compatible_with = ["//tools/platforms/hardware:raspberry_pi"],
target_type = "pi",
@@ -100,6 +102,7 @@
"//y2022/localizer:localizer_output_fbs",
"//y2022/vision:calibration_fbs",
"//y2022/vision:target_estimate_fbs",
+ "//y2022/vision:ball_color_fbs",
],
target_compatible_with = ["@platforms//os:linux"],
visibility = ["//visibility:public"],
@@ -147,6 +150,7 @@
"//aos/network:remote_message_fbs",
"//frc971/vision:vision_fbs",
"//y2022/vision:calibration_fbs",
+ "//y2022/vision:ball_color_fbs",
],
target_compatible_with = ["@platforms//os:linux"],
visibility = ["//visibility:public"],
@@ -166,6 +170,7 @@
"//aos/network:message_bridge_client_fbs",
"//aos/network:message_bridge_server_fbs",
"//aos/network:timestamp_fbs",
+ "//y2022/vision:ball_color_fbs",
"//y2019/control_loops/drivetrain:target_selector_fbs",
"//y2022/control_loops/superstructure:superstructure_goal_fbs",
"//y2022/control_loops/superstructure:superstructure_output_fbs",
diff --git a/y2022/constants.cc b/y2022/constants.cc
index 88b0e90..cb85e94 100644
--- a/y2022/constants.cc
+++ b/y2022/constants.cc
@@ -132,15 +132,19 @@
// Interpolation table for comp and practice robots
r.shot_interpolation_table = InterpolationTable<Values::ShotParams>({
- {1, {0.1, 19.0}},
- {1.9, {0.1, 19.0}}, // 1.7 in reality
- {2.12, {0.15, 18.8}}, // 2.006 in reality
- {2.9, {0.25, 19.2}}, // 2.92 in reality
- {3.8, {0.30, 20.8}}, // 3.8 in reality
- {4.9, {0.32, 22.8}}, // 4.97 in reality
- {6.9, {0.40, 24.0}}, // 6.1 in reality
- {7.9, {0.40, 25.0}}, // 6.5 in reality
- {10, {0.40, 24.0}},
+ {1.0, {0.0, 19.0}},
+ {1.6, {0.0, 19.0}},
+ {1.9, {0.1, 19.0}},
+ {2.12, {0.15, 18.8}},
+ {2.9, {0.25, 19.2}},
+
+ {3.8, {0.35, 20.6}},
+ {4.9, {0.4, 21.9}},
+ {6.0, {0.40, 24.0}},
+ {7.0, {0.40, 25.5}},
+
+ {7.8, {0.35, 26.9}},
+ {10.0, {0.35, 26.9}},
});
switch (team) {
@@ -169,16 +173,18 @@
break;
case kCompTeamNumber:
- climber->potentiometer_offset = -0.0463847608752 - 0.0376876182111;
+ climber->potentiometer_offset =
+ -0.0463847608752 - 0.0376876182111 + 0.0629263851579;
intake_front->potentiometer_offset =
- 2.79628370453323 - 0.0250288114832881;
+ 2.79628370453323 - 0.0250288114832881 + 0.577152542437606;
intake_front->subsystem_params.zeroing_constants
- .measured_absolute_position = 0.274930238824366;
+ .measured_absolute_position = 0.26963366701647;
- intake_back->potentiometer_offset = 3.1409576474047;
+ intake_back->potentiometer_offset =
+ 3.1409576474047 + 0.278653334013286 + 0.00879137908308503;
intake_back->subsystem_params.zeroing_constants
- .measured_absolute_position = 0.280099007470002;
+ .measured_absolute_position = 0.242434593996789;
turret->potentiometer_offset = -9.99970387166721 + 0.06415943 +
0.073290115367682 - 0.0634440443622909 +
diff --git a/y2022/control_loops/superstructure/superstructure.cc b/y2022/control_loops/superstructure/superstructure.cc
index 3d5284e..b21dd0d 100644
--- a/y2022/control_loops/superstructure/superstructure.cc
+++ b/y2022/control_loops/superstructure/superstructure.cc
@@ -243,7 +243,7 @@
.shooting = true});
// Dont shoot if the robot is moving faster than this
- constexpr double kMaxShootSpeed = 1.0;
+ constexpr double kMaxShootSpeed = 1.7;
const bool moving_too_fast = std::abs(robot_velocity()) > kMaxShootSpeed;
switch (state_) {
diff --git a/y2022/joystick_reader.cc b/y2022/joystick_reader.cc
index 7e7a735..baaa2ee 100644
--- a/y2022/joystick_reader.cc
+++ b/y2022/joystick_reader.cc
@@ -64,7 +64,7 @@
const ButtonLocation kIntakeFrontOut(4, 10);
const ButtonLocation kIntakeBackOut(4, 9);
const ButtonLocation kSpitFront(3, 3);
-const ButtonLocation kSpitBack(2, 3);
+const ButtonLocation kSpitBack(3, 1);
const ButtonLocation kRedLocalizerReset(4, 14);
const ButtonLocation kBlueLocalizerReset(4, 13);
@@ -226,14 +226,18 @@
constexpr double kIntakePosition = -0.02;
constexpr size_t kIntakeCounterIterations = 25;
- // Extend the intakes and spin the rollers
- if (data.IsPressed(kIntakeFrontOut)) {
+ // Extend the intakes and spin the rollers.
+ // Don't let this happen if there is a ball in the other intake, because
+ // that would spit this one out.
+ if (data.IsPressed(kIntakeFrontOut) &&
+ !superstructure_status_fetcher_->back_intake_has_ball()) {
intake_front_pos = kIntakePosition;
transfer_roller_speed = kTransferRollerSpeed;
intake_front_counter_ = kIntakeCounterIterations;
intake_back_counter_ = 0;
- } else if (data.IsPressed(kIntakeBackOut)) {
+ } else if (data.IsPressed(kIntakeBackOut) &&
+ !superstructure_status_fetcher_->front_intake_has_ball()) {
intake_back_pos = kIntakePosition;
transfer_roller_speed = -kTransferRollerSpeed;
diff --git a/y2022/vision/BUILD b/y2022/vision/BUILD
index a26c507..65ab20c 100644
--- a/y2022/vision/BUILD
+++ b/y2022/vision/BUILD
@@ -149,6 +149,73 @@
],
)
+cc_binary(
+ name = "ball_color_detector",
+ srcs = [
+ "ball_color_main.cc",
+ ],
+ target_compatible_with = ["@platforms//os:linux"],
+ visibility = ["//y2022:__subpackages__"],
+ deps = [
+ ":ball_color_lib",
+ "//aos:init",
+ "//aos/events:shm_event_loop",
+ ],
+)
+
+cc_test(
+ name = "ball_color_test",
+ srcs = [
+ "ball_color_test.cc",
+ ],
+ data = [
+ "test_ball_color_image.jpg",
+ ],
+ deps = [
+ ":ball_color_lib",
+ "//aos:json_to_flatbuffer",
+ "//aos/events:simulated_event_loop",
+ "//aos/testing:googletest",
+ "//aos/testing:test_logging",
+ "//y2022:constants",
+ ],
+)
+
+cc_library(
+ name = "ball_color_lib",
+ srcs = [
+ "ball_color.cc",
+ ],
+ hdrs = [
+ "ball_color.h",
+ ],
+ data = [
+ "//y2022:aos_config",
+ ],
+ target_compatible_with = ["@platforms//os:linux"],
+ visibility = ["//y2022:__subpackages__"],
+ deps = [
+ ":ball_color_fbs",
+ "//aos/events:event_loop",
+ "//aos/events:shm_event_loop",
+ "//aos/network:team_number",
+ "//frc971/input:joystick_state_fbs",
+ "//frc971/vision:vision_fbs",
+ "//third_party:opencv",
+ ],
+)
+
+flatbuffer_cc_library(
+ name = "ball_color_fbs",
+ srcs = ["ball_color.fbs"],
+ gen_reflections = 1,
+ includes = [
+ "//frc971/input:joystick_state_fbs_includes",
+ ],
+ target_compatible_with = ["@platforms//os:linux"],
+ visibility = ["//y2022:__subpackages__"],
+)
+
cc_library(
name = "geometry_lib",
hdrs = [
diff --git a/y2022/vision/ball_color.cc b/y2022/vision/ball_color.cc
new file mode 100644
index 0000000..e896da5
--- /dev/null
+++ b/y2022/vision/ball_color.cc
@@ -0,0 +1,138 @@
+#include "y2022/vision/ball_color.h"
+
+#include <chrono>
+#include <cmath>
+#include <opencv2/highgui/highgui.hpp>
+#include <thread>
+
+#include "aos/events/event_loop.h"
+#include "aos/events/shm_event_loop.h"
+#include "frc971/input/joystick_state_generated.h"
+#include "frc971/vision/vision_generated.h"
+#include "glog/logging.h"
+#include "opencv2/imgproc.hpp"
+
+namespace y2022 {
+namespace vision {
+
+BallColorDetector::BallColorDetector(aos::EventLoop *event_loop)
+ : ball_color_sender_(event_loop->MakeSender<BallColor>("/superstructure")) {
+ event_loop->MakeWatcher("/camera", [this](const CameraImage &camera_image) {
+ this->ProcessImage(camera_image);
+ });
+}
+
+void BallColorDetector::ProcessImage(const CameraImage &image) {
+ cv::Mat image_color_mat(cv::Size(image.cols(), image.rows()), CV_8UC2,
+ (void *)image.data()->data());
+ cv::Mat image_mat(cv::Size(image.cols(), image.rows()), CV_8UC3);
+ cv::cvtColor(image_color_mat, image_mat, cv::COLOR_YUV2BGR_YUYV);
+
+ aos::Alliance detected_color = DetectColor(image_mat);
+
+ auto builder = ball_color_sender_.MakeBuilder();
+ auto ball_color_builder = builder.MakeBuilder<BallColor>();
+ ball_color_builder.add_ball_color(detected_color);
+ builder.CheckOk(builder.Send(ball_color_builder.Finish()));
+}
+
+aos::Alliance BallColorDetector::DetectColor(cv::Mat image) {
+ cv::Mat hsv(cv::Size(image.cols, image.rows), CV_8UC3);
+
+ cv::cvtColor(image, hsv, cv::COLOR_BGR2HSV);
+
+ // Look at 3 chunks of the image
+ cv::Mat reference_red =
+ BallColorDetector::SubImage(hsv, BallColorDetector::kReferenceRed());
+
+ cv::Mat reference_blue =
+ BallColorDetector::SubImage(hsv, BallColorDetector::kReferenceBlue());
+ cv::Mat ball_location =
+ BallColorDetector::SubImage(hsv, BallColorDetector::kBallLocation());
+
+ // OpenCV HSV hues go from [0 to 179]
+ // Average the average color of each patch in both directions
+ // Rejecting pixels that have too low saturation or to bright or dark value
+ // And dealing with the wrapping of the red hues by shifting the wrap to be
+ // around 90 instead of 180. 90 is a color we don't care about.
+ double red = BallColorDetector::mean_hue(reference_red);
+ double blue = BallColorDetector::mean_hue(reference_blue);
+ double ball = BallColorDetector::mean_hue(ball_location);
+
+ // Just look at the hue values for distance
+ const double distance_to_blue = std::abs(ball - blue);
+ const double distance_to_red = std::abs(ball - red);
+
+ VLOG(1) << "\n"
+ << "Red: " << red << " deg\n"
+ << "Blue: " << blue << " deg\n"
+ << "Ball: " << ball << " deg\n"
+ << "distance to blue: " << distance_to_blue << " "
+ << "distance_to_red: " << distance_to_red;
+
+ // Is the ball location close enough to being the same hue as the blue
+ // reference or the red reference?
+
+ if (distance_to_blue < distance_to_red &&
+ distance_to_blue < kMaxHueDistance) {
+ return aos::Alliance::kBlue;
+ } else if (distance_to_red < distance_to_blue &&
+ distance_to_red < kMaxHueDistance) {
+ return aos::Alliance::kRed;
+ }
+
+ return aos::Alliance::kInvalid;
+}
+
+cv::Mat BallColorDetector::SubImage(cv::Mat image, cv::Rect location) {
+ cv::Rect new_location = BallColorDetector::RescaleRect(
+ image, location, BallColorDetector::kMeasurementsImageSize());
+ return image(new_location);
+}
+
+// Handle varying size images by scaling our constants rectangles
+cv::Rect BallColorDetector::RescaleRect(cv::Mat image, cv::Rect location,
+ cv::Size original_size) {
+ const double x_scale = static_cast<double>(image.cols) / original_size.width;
+ const double y_scale = static_cast<double>(image.rows) / original_size.height;
+
+ cv::Rect new_location(location.x * x_scale, location.y * y_scale,
+ location.width * x_scale, location.height * y_scale);
+
+ return new_location;
+}
+
+double BallColorDetector::mean_hue(cv::Mat hsv_image) {
+ double num_pixels_selected = 0;
+ double sum = 0;
+
+ for (int i = 0; i < hsv_image.rows; ++i) {
+ for (int j = 0; j < hsv_image.cols; ++j) {
+ const cv::Vec3b &color = hsv_image.at<cv::Vec3b>(i, j);
+ double value = static_cast<double>(color(2));
+ double saturation = static_cast<double>(color(1));
+
+ if (value < kMinValue || value > kMaxValue ||
+ saturation < kMinSaturation) {
+ continue;
+ }
+
+ // unwrap hue so that break is around 90 instead of 180
+ // ex. a hue of 180 goes to 0, a hue of 120 goes to -60
+ // but there's still a break around 90 where it will be either +- 90
+ // depending on which side it's on
+ double hue = static_cast<double>(color(0));
+ if (hue > 90) {
+ hue = hue - 180;
+ }
+
+ num_pixels_selected++;
+ sum += hue;
+ }
+ }
+
+ return sum / num_pixels_selected;
+}
+
+} // namespace vision
+} // namespace y2022
diff --git a/y2022/vision/ball_color.fbs b/y2022/vision/ball_color.fbs
new file mode 100644
index 0000000..7eb93e0
--- /dev/null
+++ b/y2022/vision/ball_color.fbs
@@ -0,0 +1,12 @@
+include "frc971/input/joystick_state.fbs";
+
+namespace y2022.vision;
+
+table BallColor {
+ // The color of the ball represented as which alliance it belongs to
+ // it will be unpredictable when there is no ball and it will be kInvalid
+ // if the color is not close enough to either of the two references.
+ ball_color:aos.Alliance (id: 0);
+}
+
+root_type BallColor;
diff --git a/y2022/vision/ball_color.h b/y2022/vision/ball_color.h
new file mode 100644
index 0000000..ef3bdd2
--- /dev/null
+++ b/y2022/vision/ball_color.h
@@ -0,0 +1,58 @@
+#ifndef Y2022_VISION_BALL_COLOR_H_
+#define Y2022_VISION_BALL_COLOR_H_
+
+#include <opencv2/imgproc.hpp>
+
+#include "aos/events/shm_event_loop.h"
+#include "frc971/input/joystick_state_generated.h"
+#include "frc971/vision/vision_generated.h"
+#include "y2022/vision/ball_color_generated.h"
+
+namespace y2022 {
+namespace vision {
+
+using namespace frc971::vision;
+
+// Takes in camera images and detects what color the loaded ball is
+// Does not detect if there is a ball, and will output bad measurements in
+// the case that that there is not a ball.
+class BallColorDetector {
+ public:
+ // The size image that the reference rectangles were measure with
+ // These constants will be scaled if the image sent is not the same size
+ static const cv::Size kMeasurementsImageSize() { return {640, 480}; };
+ static const cv::Rect kReferenceRed() { return {440, 150, 50, 130}; };
+ static const cv::Rect kReferenceBlue() { return {440, 350, 30, 100}; };
+ static const cv::Rect kBallLocation() { return {100, 400, 140, 50}; };
+
+ // Constants used to filter out pixels that don't have good color information
+ static constexpr double kMinSaturation = 128;
+ static constexpr double kMinValue = 25;
+ static constexpr double kMaxValue = 230;
+
+ static constexpr double kMaxHueDistance = 10;
+
+ BallColorDetector(aos::EventLoop *event_loop);
+
+ void ProcessImage(const CameraImage &camera_image);
+
+ // We look at three parts of the image: two reference locations where there
+ // will be red and blue markers that should match the ball, and then the
+ // location in the catapult where we expect to see the ball. We then compute
+ // the average hue of each patch but discard pixels that we deem not colorful
+ // enough. Then we decide whether the ball color looks close enough to either
+ // of the reference colors. If no good color is detected, outputs kInvalid.
+ static aos::Alliance DetectColor(cv::Mat image);
+
+ static cv::Mat SubImage(cv::Mat image, cv::Rect location);
+
+ static cv::Rect RescaleRect(cv::Mat image, cv::Rect location,
+ cv::Size original_size);
+ static double mean_hue(cv::Mat hsv_image);
+
+ private:
+ aos::Sender<BallColor> ball_color_sender_;
+};
+} // namespace vision
+} // namespace y2022
+#endif
diff --git a/y2022/vision/ball_color_main.cc b/y2022/vision/ball_color_main.cc
new file mode 100644
index 0000000..63f9d06
--- /dev/null
+++ b/y2022/vision/ball_color_main.cc
@@ -0,0 +1,35 @@
+#include "aos/events/shm_event_loop.h"
+#include "aos/init.h"
+#include "y2022/vision/ball_color.h"
+
+// config used to allow running ball_color_detector independently. E.g.,
+// bazel run //y2022/vision:ball_color_detector -- --config
+// y2022/aos_config.json
+// --override_hostname pi-7971-1 --ignore_timestamps true
+DEFINE_string(config, "aos_config.json", "Path to the config file to use.");
+
+namespace y2022 {
+namespace vision {
+namespace {
+
+using namespace frc971::vision;
+
+void BallColorDetectorMain() {
+ aos::FlatbufferDetachedBuffer<aos::Configuration> config =
+ aos::configuration::ReadConfig(FLAGS_config);
+
+ aos::ShmEventLoop event_loop(&config.message());
+
+ BallColorDetector ball_color_detector(&event_loop);
+
+ event_loop.Run();
+}
+
+} // namespace
+} // namespace vision
+} // namespace y2022
+
+int main(int argc, char **argv) {
+ aos::InitGoogle(&argc, &argv);
+ y2022::vision::BallColorDetectorMain();
+}
diff --git a/y2022/vision/ball_color_test.cc b/y2022/vision/ball_color_test.cc
new file mode 100644
index 0000000..695791b
--- /dev/null
+++ b/y2022/vision/ball_color_test.cc
@@ -0,0 +1,147 @@
+#include "y2022/vision/ball_color.h"
+
+#include <cmath>
+#include <opencv2/highgui/highgui.hpp>
+#include <opencv2/imgproc.hpp>
+
+#include "aos/events/simulated_event_loop.h"
+#include "aos/json_to_flatbuffer.h"
+#include "aos/testing/test_logging.h"
+#include "glog/logging.h"
+#include "gtest/gtest.h"
+#include "y2022/constants.h"
+
+DEFINE_string(output_folder, "",
+ "If set, logs all channels to the provided logfile.");
+
+namespace y2022::vision::testing {
+
+class BallColorTest : public ::testing::Test {
+ public:
+ BallColorTest()
+ : config_(aos::configuration::ReadConfig("y2022/aos_config.json")),
+ event_loop_factory_(&config_.message()),
+ logger_pi_(aos::configuration::GetNode(
+ event_loop_factory_.configuration(), "logger")),
+ roborio_(aos::configuration::GetNode(
+ event_loop_factory_.configuration(), "roborio")),
+ camera_event_loop_(
+ event_loop_factory_.MakeEventLoop("Camera", logger_pi_)),
+ color_detector_event_loop_(event_loop_factory_.MakeEventLoop(
+ "Ball color detector", logger_pi_)),
+ superstructure_event_loop_(
+ event_loop_factory_.MakeEventLoop("Superstructure", roborio_)),
+ ball_color_fetcher_(superstructure_event_loop_->MakeFetcher<BallColor>(
+ "/superstructure")),
+ image_sender_(camera_event_loop_->MakeSender<CameraImage>("/camera"))
+
+ {}
+
+ // copied from camera_reader.cc
+ void SendImage(cv::Mat bgr_image) {
+ cv::Mat image_color_mat;
+ cv::cvtColor(bgr_image, image_color_mat, cv::COLOR_BGR2YUV);
+
+ // Convert YUV (3 channels) to YUYV (stacked format)
+ std::vector<uint8_t> yuyv;
+ for (int i = 0; i < image_color_mat.rows; i++) {
+ for (int j = 0; j < image_color_mat.cols; j++) {
+ // Always push a Y value
+ yuyv.emplace_back(image_color_mat.at<cv::Vec3b>(i, j)[0]);
+ if ((j % 2) == 0) {
+ // If column # is even, push a U value.
+ yuyv.emplace_back(image_color_mat.at<cv::Vec3b>(i, j)[1]);
+ } else {
+ // If column # is odd, push a V value.
+ yuyv.emplace_back(image_color_mat.at<cv::Vec3b>(i, j)[2]);
+ }
+ }
+ }
+
+ CHECK_EQ(static_cast<int>(yuyv.size()),
+ image_color_mat.rows * image_color_mat.cols * 2);
+
+ auto builder = image_sender_.MakeBuilder();
+ auto image_offset = builder.fbb()->CreateVector(yuyv);
+ auto image_builder = builder.MakeBuilder<CameraImage>();
+
+ int64_t timestamp = aos::monotonic_clock::now().time_since_epoch().count();
+
+ image_builder.add_rows(image_color_mat.rows);
+ image_builder.add_cols(image_color_mat.cols);
+ image_builder.add_data(image_offset);
+ image_builder.add_monotonic_timestamp_ns(timestamp);
+
+ builder.CheckOk(builder.Send(image_builder.Finish()));
+ }
+
+ aos::FlatbufferDetachedBuffer<aos::Configuration> config_;
+ aos::SimulatedEventLoopFactory event_loop_factory_;
+ const aos::Node *const logger_pi_;
+ const aos::Node *const roborio_;
+ ::std::unique_ptr<::aos::EventLoop> camera_event_loop_;
+ ::std::unique_ptr<::aos::EventLoop> color_detector_event_loop_;
+ ::std::unique_ptr<::aos::EventLoop> superstructure_event_loop_;
+ aos::Fetcher<BallColor> ball_color_fetcher_;
+ aos::Sender<CameraImage> image_sender_;
+};
+
+TEST_F(BallColorTest, DetectColorFromTestImage) {
+ cv::Mat bgr_image =
+ cv::imread("y2022/vision/test_ball_color_image.jpg", cv::IMREAD_COLOR);
+
+ ASSERT_TRUE(bgr_image.data != nullptr);
+
+ aos::Alliance detected_color = BallColorDetector::DetectColor(bgr_image);
+
+ EXPECT_EQ(detected_color, aos::Alliance::kRed);
+}
+
+TEST_F(BallColorTest, DetectColorFromTestImageInEventLoop) {
+ cv::Mat bgr_image =
+ cv::imread("y2022/vision/test_ball_color_image.jpg", cv::IMREAD_COLOR);
+ ASSERT_TRUE(bgr_image.data != nullptr);
+
+ BallColorDetector detector(color_detector_event_loop_.get());
+
+ camera_event_loop_->OnRun([this, bgr_image]() { SendImage(bgr_image); });
+
+ event_loop_factory_.RunFor(std::chrono::milliseconds(5));
+
+ ASSERT_TRUE(ball_color_fetcher_.Fetch());
+
+ EXPECT_TRUE(ball_color_fetcher_->has_ball_color());
+ EXPECT_EQ(ball_color_fetcher_->ball_color(), aos::Alliance::kRed);
+}
+
+TEST_F(BallColorTest, TestRescaling) {
+ cv::Mat mat(cv::Size(320, 240), CV_8UC3);
+ cv::Rect new_rect = BallColorDetector::RescaleRect(
+ mat, cv::Rect(30, 30, 30, 30), cv::Size(1920, 1080));
+
+ EXPECT_EQ(new_rect, cv::Rect(5, 6, 5, 6));
+}
+
+TEST_F(BallColorTest, TestAreas) {
+ cv::Mat bgr_image =
+ cv::imread("y2022/vision/test_ball_color_image.jpg", cv::IMREAD_COLOR);
+ ASSERT_TRUE(bgr_image.data != nullptr);
+
+ cv::Rect reference_red = BallColorDetector::RescaleRect(
+ bgr_image, BallColorDetector::kReferenceRed(),
+ BallColorDetector::kMeasurementsImageSize());
+ cv::Rect reference_blue = BallColorDetector::RescaleRect(
+ bgr_image, BallColorDetector::kReferenceBlue(),
+ BallColorDetector::kMeasurementsImageSize());
+ cv::Rect ball_location = BallColorDetector::RescaleRect(
+ bgr_image, BallColorDetector::kBallLocation(),
+ BallColorDetector::kMeasurementsImageSize());
+
+ cv::rectangle(bgr_image, reference_red, cv::Scalar(0, 0, 255));
+ cv::rectangle(bgr_image, reference_blue, cv::Scalar(255, 0, 0));
+ cv::rectangle(bgr_image, ball_location, cv::Scalar(0, 255, 0));
+
+ cv::imwrite("/tmp/rectangles.jpg", bgr_image);
+}
+
+} // namespace y2022::vision::testing
diff --git a/y2022/vision/camera_definition.py b/y2022/vision/camera_definition.py
index 219a41f..61789cb 100644
--- a/y2022/vision/camera_definition.py
+++ b/y2022/vision/camera_definition.py
@@ -100,13 +100,13 @@
if pi_number == "pi1":
camera_yaw = 90.0 * np.pi / 180.0
- T = np.array([-7.0 * 0.0254, 3.5 * 0.0254, 32.0 * 0.0254])
+ T = np.array([-8.25 * 0.0254, 3.25 * 0.0254, 32.0 * 0.0254])
elif pi_number == "pi2":
camera_yaw = 0.0
- T = np.array([-7.0 * 0.0254, -3.0 * 0.0254, 34.0 * 0.0254])
+ T = np.array([-7.5 * 0.0254, -3.5 * 0.0254, 34.0 * 0.0254])
elif pi_number == "pi3":
camera_yaw = 179.0 * np.pi / 180.0
- T = np.array([-1.0 * 0.0254, 8.5 * 0.0254, 34.0 * 0.0254])
+ T = np.array([-1.0 * 0.0254, 8.5 * 0.0254, 34.25 * 0.0254])
elif pi_number == "pi4":
camera_yaw = -90.0 * np.pi / 180.0
T = np.array([-9.0 * 0.0254, -5 * 0.0254, 27.5 * 0.0254])
diff --git a/y2022/vision/target_estimator.cc b/y2022/vision/target_estimator.cc
index 377778d..9eef390 100644
--- a/y2022/vision/target_estimator.cc
+++ b/y2022/vision/target_estimator.cc
@@ -98,14 +98,22 @@
const std::array<cv::Point3d, 4> TargetEstimator::kMiddleTapePiecePoints =
ComputeMiddleTapePiecePoints();
+namespace {
+constexpr double kDefaultDistance = 3.0;
+constexpr double kDefaultYaw = M_PI;
+constexpr double kDefaultAngleToCamera = 0.0;
+} // namespace
+
TargetEstimator::TargetEstimator(cv::Mat intrinsics, cv::Mat extrinsics)
: blob_stats_(),
+ middle_blob_index_(0),
+ max_blob_area_(0.0),
image_(std::nullopt),
roll_(0.0),
pitch_(0.0),
- yaw_(M_PI),
- distance_(3.0),
- angle_to_camera_(0.0),
+ yaw_(kDefaultYaw),
+ distance_(kDefaultDistance),
+ angle_to_camera_(kDefaultAngleToCamera),
// Seed camera height
camera_height_(extrinsics.at<double>(2, 3) +
constants::Values::kImuHeight()) {
@@ -153,11 +161,17 @@
blob_stats_[2].centroid});
CHECK(circle.has_value());
+ max_blob_area_ = 0.0;
+
// Find the middle blob, which is the one with the angle closest to the
// average
double theta_avg = 0.0;
for (const auto &stats : blob_stats_) {
theta_avg += circle->AngleOf(stats.centroid);
+
+ if (stats.area > max_blob_area_) {
+ max_blob_area_ = stats.area;
+ }
}
theta_avg /= blob_stats_.size();
@@ -201,6 +215,19 @@
// TODO(milind): seed with localizer output as well
+ // If we didn't solve well last time, seed everything at the defaults so we
+ // don't get stuck in a bad state.
+ // Copied from localizer.cc
+ constexpr double kMinConfidence = 0.75;
+ if (confidence_ < kMinConfidence) {
+ roll_ = roll_seed;
+ pitch_ = pitch_seed;
+ yaw_ = kDefaultYaw;
+ distance_ = kDefaultDistance;
+ angle_to_camera_ = kDefaultAngleToCamera;
+ camera_height_ = extrinsics_(2, 3) + constants::Values::kImuHeight();
+ }
+
// Constrain the rotation to be around the localizer's, otherwise there can be
// multiple solutions. There shouldn't be too much roll or pitch
if (FLAGS_freeze_roll) {
@@ -250,7 +277,7 @@
<< std::chrono::duration<double, std::milli>(end - start).count()
<< " ms";
- // For computing the confidence, find the standard deviation in pixels
+ // For computing the confidence, find the standard deviation in pixels.
std::vector<double> residual(num_residuals);
(*this)(&roll_, &pitch_, &yaw_, &distance_, &angle_to_camera_,
&camera_height_, residual.data());
@@ -427,13 +454,18 @@
for (size_t i = 0; i < tape_indices.size(); ++i) {
const auto distance = DistanceFromTapeIndex(
tape_indices[i].second, tape_indices[i].first, tape_points_proj);
+ // Scale the distance based on the blob area: larger blobs have less noise.
+ const S distance_scalar =
+ S(blob_stats_[tape_indices[i].second].area / max_blob_area_);
VLOG(2) << "Blob index " << tape_indices[i].second << " maps to "
<< tape_indices[i].first << " distance " << distance.x << " "
- << distance.y;
+ << distance.y << " distance scalar "
+ << ScalarToDouble(distance_scalar);
+
// Set the residual to the (x, y) distance of the centroid from the
// matched projected piece of tape
- residual[i * 2] = distance.x;
- residual[(i * 2) + 1] = distance.y;
+ residual[i * 2] = distance_scalar * distance.x;
+ residual[(i * 2) + 1] = distance_scalar * distance.y;
}
// Penalize based on the difference between the size of the projected piece of
@@ -594,7 +626,9 @@
const auto kTextColor = cv::Scalar(0, 255, 255);
constexpr double kFontScale = 0.6;
- cv::putText(view_image, absl::StrFormat("Distance: %.3f", distance_),
+ cv::putText(view_image,
+ absl::StrFormat("Distance: %.3f m (%.3f in)", distance_,
+ distance_ / 0.0254),
cv::Point(kTextX, text_y += kTextSpacing),
cv::FONT_HERSHEY_DUPLEX, kFontScale, kTextColor, 2);
cv::putText(view_image,
diff --git a/y2022/vision/target_estimator.h b/y2022/vision/target_estimator.h
index f158626..ac170e8 100644
--- a/y2022/vision/target_estimator.h
+++ b/y2022/vision/target_estimator.h
@@ -73,8 +73,7 @@
template <typename S>
cv::Point_<S> DistanceFromTapeIndex(
- size_t centroid_index,
- size_t tape_index,
+ size_t centroid_index, size_t tape_index,
const std::vector<cv::Point_<S>> &tape_points) const;
void DrawProjectedHub(const std::vector<cv::Point2d> &tape_points_proj,
@@ -82,6 +81,7 @@
std::vector<BlobDetector::BlobStats> blob_stats_;
size_t middle_blob_index_;
+ double max_blob_area_;
std::optional<cv::Mat> image_;
Eigen::Matrix3d intrinsics_;
diff --git a/y2022/vision/test_ball_color_image.jpg b/y2022/vision/test_ball_color_image.jpg
new file mode 100644
index 0000000..8750460
--- /dev/null
+++ b/y2022/vision/test_ball_color_image.jpg
Binary files differ
diff --git a/y2022/vision/viewer.cc b/y2022/vision/viewer.cc
index a21c09f..446f1f6 100644
--- a/y2022/vision/viewer.cc
+++ b/y2022/vision/viewer.cc
@@ -274,15 +274,14 @@
blob_result.filtered_blobs.size()
<< ")";
+ estimator.Solve(blob_result.filtered_stats,
+ FLAGS_display_estimation ? std::make_optional(ret_image)
+ : std::nullopt);
if (blob_result.filtered_blobs.size() > 0) {
- estimator.Solve(blob_result.filtered_stats,
- FLAGS_display_estimation ? std::make_optional(ret_image)
- : std::nullopt);
estimator.DrawEstimate(ret_image);
LOG(INFO) << "Read file " << (it - file_list.begin()) << ": " << *it;
}
-
cv::imshow("image", image_mat);
cv::imshow("mask", blob_result.binarized_image);
cv::imshow("blobs", ret_image);
diff --git a/y2022/www/field_handler.ts b/y2022/www/field_handler.ts
index 6c2df51..22989be 100644
--- a/y2022/www/field_handler.ts
+++ b/y2022/www/field_handler.ts
@@ -280,34 +280,6 @@
// Draw the matches with debugging information from the localizer.
const now = Date.now() / 1000.0;
- for (const [time, value] of this.localizerImageMatches) {
- const age = now - time;
- const kRemovalAge = 2.0;
- if (age > kRemovalAge) {
- this.localizerImageMatches.delete(time);
- continue;
- }
- const ageAlpha = (kRemovalAge - age) / kRemovalAge
- for (let i = 0; i < value.targetsLength(); i++) {
- const imageDebug = value.targets(i);
- const x = imageDebug.impliedRobotX();
- const y = imageDebug.impliedRobotY();
- const theta = imageDebug.impliedRobotTheta();
- const cameraX = imageDebug.cameraX();
- const cameraY = imageDebug.cameraY();
- const cameraTheta = imageDebug.cameraTheta();
- const accepted = imageDebug.accepted();
- // Make camera readings fade over time.
- const alpha = Math.round(255 * ageAlpha).toString(16).padStart(2, '0');
- const dashed = false;
- const acceptedRgb = accepted ? '#00FF00' : '#FF0000';
- const acceptedRgba = acceptedRgb + alpha;
- const cameraRgb = PI_COLORS[imageDebug.camera()];
- const cameraRgba = cameraRgb + alpha;
- this.drawRobot(x, y, theta, null, acceptedRgba, dashed, false);
- this.drawCamera(cameraX, cameraY, cameraTheta, cameraRgba, false);
- }
- }
if (this.superstructureStatus) {
this.shotDistance.innerHTML = this.superstructureStatus.aimer() ?
(this.superstructureStatus.aimer().shotDistance() /
@@ -424,6 +396,36 @@
null);
}
+ for (const [time, value] of this.localizerImageMatches) {
+ const age = now - time;
+ const kRemovalAge = 1.0;
+ if (age > kRemovalAge) {
+ this.localizerImageMatches.delete(time);
+ continue;
+ }
+ const kMaxImageAlpha = 0.5;
+ const ageAlpha = kMaxImageAlpha * (kRemovalAge - age) / kRemovalAge
+ for (let i = 0; i < value.targetsLength(); i++) {
+ const imageDebug = value.targets(i);
+ const x = imageDebug.impliedRobotX();
+ const y = imageDebug.impliedRobotY();
+ const theta = imageDebug.impliedRobotTheta();
+ const cameraX = imageDebug.cameraX();
+ const cameraY = imageDebug.cameraY();
+ const cameraTheta = imageDebug.cameraTheta();
+ const accepted = imageDebug.accepted();
+ // Make camera readings fade over time.
+ const alpha = Math.round(255 * ageAlpha).toString(16).padStart(2, '0');
+ const dashed = false;
+ const acceptedRgb = accepted ? '#00FF00' : '#FF0000';
+ const acceptedRgba = acceptedRgb + alpha;
+ const cameraRgb = PI_COLORS[imageDebug.camera()];
+ const cameraRgba = cameraRgb + alpha;
+ this.drawRobot(x, y, theta, null, acceptedRgba, dashed, false);
+ this.drawCamera(cameraX, cameraY, cameraTheta, cameraRgba, false);
+ }
+ }
+
window.requestAnimationFrame(() => this.draw());
}
diff --git a/y2022/y2022_logger.json b/y2022/y2022_logger.json
index f811dc8..f54ccd7 100644
--- a/y2022/y2022_logger.json
+++ b/y2022/y2022_logger.json
@@ -19,6 +19,38 @@
]
},
{
+ "name": "/superstructure",
+ "type": "y2022.vision.BallColor",
+ "source_node": "logger",
+ "logger": "LOCAL_AND_REMOTE_LOGGER",
+ "logger_nodes": [
+ "roborio"
+ ],
+ "frequency": 200,
+ "num_senders": 2,
+ "max_size": 72,
+ "destination_nodes": [
+ {
+ "name": "roborio",
+ "priority": 2,
+ "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+ "timestamp_logger_nodes": [
+ "roborio"
+ ],
+ "time_to_live": 5000000
+ }
+ ]
+ },
+ {
+ "name": "/aos/remote_timestamps/roborio/superstructure/y2022-vision-BallColor",
+ "type": "aos.message_bridge.RemoteMessage",
+ "source_node": "logger",
+ "logger": "NOT_LOGGED",
+ "frequency": 20,
+ "num_senders": 2,
+ "max_size": 200
+ },
+ {
"name": "/drivetrain",
"type": "frc971.control_loops.drivetrain.Position",
"source_node": "roborio",
@@ -72,7 +104,7 @@
"type": "aos.message_bridge.RemoteMessage",
"source_node": "roborio",
"logger": "NOT_LOGGED",
- "frequency": 200,
+ "frequency": 400,
"num_senders": 2,
"max_size": 400
},
@@ -481,6 +513,13 @@
"nodes": [
"logger"
]
+ },
+ {
+ "name": "ball_color_detector",
+ "executable_name": "ball_color_detector",
+ "nodes": [
+ "logger"
+ ]
}
],
"nodes": [
diff --git a/y2022/y2022_pi_template.json b/y2022/y2022_pi_template.json
index 6eddf9a..bcd3f6b 100644
--- a/y2022/y2022_pi_template.json
+++ b/y2022/y2022_pi_template.json
@@ -181,7 +181,7 @@
"source_node": "pi{{ NUM }}",
"frequency": 25,
"num_senders": 2,
- "max_size": 20000,
+ "max_size": 40000,
"logger": "LOCAL_AND_REMOTE_LOGGER",
"logger_nodes": [
"imu",
@@ -211,14 +211,14 @@
{
"name": "/pi{{ NUM }}/aos/remote_timestamps/imu/pi{{ NUM }}/camera/y2022-vision-TargetEstimate",
"type": "aos.message_bridge.RemoteMessage",
- "frequency": 20,
+ "frequency": 40,
"source_node": "pi{{ NUM }}",
"max_size": 208
},
{
"name": "/pi{{ NUM }}/aos/remote_timestamps/logger/pi{{ NUM }}/camera/y2022-vision-TargetEstimate",
"type": "aos.message_bridge.RemoteMessage",
- "frequency": 20,
+ "frequency": 40,
"source_node": "pi{{ NUM }}",
"max_size": 208
},
diff --git a/y2022/y2022_roborio.json b/y2022/y2022_roborio.json
index 93e0483..8651923 100644
--- a/y2022/y2022_roborio.json
+++ b/y2022/y2022_roborio.json
@@ -240,7 +240,7 @@
"name": "/superstructure",
"type": "y2022.control_loops.superstructure.Status",
"source_node": "roborio",
- "frequency": 200,
+ "frequency": 400,
"num_senders": 2,
"logger": "LOCAL_AND_REMOTE_LOGGER",
"logger_nodes": [
@@ -317,6 +317,21 @@
"num_senders": 2
},
{
+ "name": "/superstructure",
+ "type": "y2022.vision.BallColor",
+ "source_node": "logger",
+ "frequency": 200,
+ "num_senders": 2,
+ "max_size": 72,
+ "destination_nodes": [
+ {
+ "name": "roborio",
+ "priority": 2,
+ "time_to_live": 500000000
+ }
+ ]
+ },
+ {
"name": "/drivetrain",
"type": "frc971.sensors.GyroReading",
"source_node": "roborio",