Fix dashboard + vision: region checkboxes, upload auto-switch, state detection, analyze endpoint
- Region checkboxes now control which overlays are drawn (?regions= param) - Upload auto-switches capture source (no restart needed) - State detection: check in-game BEFORE loading, require 90% dark for loading - Tighten HSV ranges: health H 0-30 S 50+ V 30+, mana H 200-240 S 40+ V 20+ - Add /api/analyze endpoint with per-region color analysis
This commit is contained in:
parent
67f2e5536a
commit
a665253d4d
5 changed files with 277 additions and 47 deletions
BIN
debug
Executable file
BIN
debug
Executable file
Binary file not shown.
212
pkg/api/api.go
212
pkg/api/api.go
|
|
@ -34,9 +34,11 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.cloonar.com/openclawd/iso-bot/pkg/engine"
|
"git.cloonar.com/openclawd/iso-bot/pkg/engine"
|
||||||
|
"git.cloonar.com/openclawd/iso-bot/pkg/engine/capture/backends"
|
||||||
"git.cloonar.com/openclawd/iso-bot/pkg/engine/vision"
|
"git.cloonar.com/openclawd/iso-bot/pkg/engine/vision"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -73,6 +75,33 @@ type RegionInfo struct {
|
||||||
Height int `json:"height"`
|
Height int `json:"height"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HSVColor represents an HSV color value.
|
||||||
|
type HSVColor struct {
|
||||||
|
H int `json:"h"`
|
||||||
|
S int `json:"s"`
|
||||||
|
V int `json:"v"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegionAnalysis represents analysis results for a specific region.
|
||||||
|
type RegionAnalysis struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DominantHSV HSVColor `json:"dominantHSV"`
|
||||||
|
MatchPercent float64 `json:"matchPercent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalysisResponse represents the response from /api/analyze endpoint.
|
||||||
|
type AnalysisResponse struct {
|
||||||
|
GameState string `json:"gameState"`
|
||||||
|
Vitals VitalsInfo `json:"vitals"`
|
||||||
|
Regions []RegionAnalysis `json:"regions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VitalsInfo represents vital statistics.
|
||||||
|
type VitalsInfo struct {
|
||||||
|
HealthPct float64 `json:"healthPct"`
|
||||||
|
ManaPct float64 `json:"manaPct"`
|
||||||
|
}
|
||||||
|
|
||||||
// Server provides the HTTP API and WebSocket endpoint.
|
// Server provides the HTTP API and WebSocket endpoint.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
@ -114,6 +143,7 @@ func (s *Server) registerRoutes() {
|
||||||
s.mux.HandleFunc("GET /api/pixel", s.handlePixel)
|
s.mux.HandleFunc("GET /api/pixel", s.handlePixel)
|
||||||
s.mux.HandleFunc("GET /api/config", s.handleConfig)
|
s.mux.HandleFunc("GET /api/config", s.handleConfig)
|
||||||
s.mux.HandleFunc("GET /api/plugins", s.handlePlugins)
|
s.mux.HandleFunc("GET /api/plugins", s.handlePlugins)
|
||||||
|
s.mux.HandleFunc("GET /api/analyze", s.handleAnalyze)
|
||||||
s.mux.HandleFunc("POST /api/start", s.handleStart)
|
s.mux.HandleFunc("POST /api/start", s.handleStart)
|
||||||
s.mux.HandleFunc("POST /api/stop", s.handleStop)
|
s.mux.HandleFunc("POST /api/stop", s.handleStop)
|
||||||
s.mux.HandleFunc("POST /api/pause", s.handlePause)
|
s.mux.HandleFunc("POST /api/pause", s.handlePause)
|
||||||
|
|
@ -190,8 +220,29 @@ func (s *Server) handleCaptureFrameAnnotated(w http.ResponseWriter, r *http.Requ
|
||||||
annotated := image.NewRGBA(bounds)
|
annotated := image.NewRGBA(bounds)
|
||||||
draw.Draw(annotated, bounds, frame, bounds.Min, draw.Src)
|
draw.Draw(annotated, bounds, frame, bounds.Min, draw.Src)
|
||||||
|
|
||||||
// Draw region overlays
|
// Get regions to draw (filtered by query parameter if present)
|
||||||
regions := s.getRegions()
|
regions := s.getRegions()
|
||||||
|
|
||||||
|
// Filter regions by query parameter if provided
|
||||||
|
if r.URL.Query().Has("regions") {
|
||||||
|
regionsParam := r.URL.Query().Get("regions")
|
||||||
|
if regionsParam == "" {
|
||||||
|
regions = nil // Empty param = no overlays
|
||||||
|
} else {
|
||||||
|
regionNames := strings.Split(regionsParam, ",")
|
||||||
|
filteredRegions := make([]RegionInfo, 0)
|
||||||
|
for _, region := range regions {
|
||||||
|
for _, name := range regionNames {
|
||||||
|
if strings.TrimSpace(name) == region.Name {
|
||||||
|
filteredRegions = append(filteredRegions, region)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
regions = filteredRegions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
s.drawRegionOverlays(annotated, regions)
|
s.drawRegionOverlays(annotated, regions)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "image/jpeg")
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
|
@ -252,12 +303,42 @@ func (s *Server) handleCaptureUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Auto-switch capture source to uploaded file
|
// Auto-switch capture source to uploaded file
|
||||||
// For now, just inform user they need to restart with the new file
|
fileConfig := map[string]interface{}{
|
||||||
|
"path": filename,
|
||||||
|
"type": "image",
|
||||||
|
}
|
||||||
|
|
||||||
|
newSource, err := backends.NewFileSource(fileConfig)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create file source: %v", err)
|
||||||
response := map[string]string{
|
response := map[string]string{
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"message": fmt.Sprintf("File uploaded to %s. Restart with --capture-file to use it.", filename),
|
"message": fmt.Sprintf("File uploaded to %s but failed to switch capture source: %v", filename, err),
|
||||||
"autoSwitch": "false", // Feature not implemented yet
|
"autoSwitch": "failed",
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.engine.SetCaptureSource(newSource)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to set capture source: %v", err)
|
||||||
|
response := map[string]string{
|
||||||
|
"filename": filename,
|
||||||
|
"message": fmt.Sprintf("File uploaded to %s but failed to switch capture source: %v", filename, err),
|
||||||
|
"autoSwitch": "failed",
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]string{
|
||||||
|
"filename": filename,
|
||||||
|
"message": fmt.Sprintf("File uploaded to %s and capture source switched successfully", filename),
|
||||||
|
"autoSwitch": "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
@ -470,6 +551,40 @@ func (s *Server) handlePause(w http.ResponseWriter, r *http.Request) {
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAnalyze(w http.ResponseWriter, r *http.Request) {
|
||||||
|
frame := s.engine.CurrentFrame()
|
||||||
|
if frame == nil {
|
||||||
|
http.Error(w, "No frame available", http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := s.engine.Status()
|
||||||
|
|
||||||
|
// Get vitals
|
||||||
|
vitals := VitalsInfo{
|
||||||
|
HealthPct: status.Vitals.HealthPct,
|
||||||
|
ManaPct: status.Vitals.ManaPct,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get regions and analyze each one
|
||||||
|
regions := s.getRegions()
|
||||||
|
regionAnalyses := make([]RegionAnalysis, 0, len(regions))
|
||||||
|
|
||||||
|
for _, region := range regions {
|
||||||
|
analysis := s.analyzeRegion(frame, region)
|
||||||
|
regionAnalyses = append(regionAnalyses, analysis)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := AnalysisResponse{
|
||||||
|
GameState: string(status.GameState),
|
||||||
|
Vitals: vitals,
|
||||||
|
Regions: regionAnalyses,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|
||||||
func (s *Server) getRegions() []RegionInfo {
|
func (s *Server) getRegions() []RegionInfo {
|
||||||
|
|
@ -498,6 +613,93 @@ func (s *Server) getRegions() []RegionInfo {
|
||||||
return regions
|
return regions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) analyzeRegion(frame image.Image, region RegionInfo) RegionAnalysis {
|
||||||
|
rect := image.Rect(region.X, region.Y, region.X+region.Width, region.Y+region.Height)
|
||||||
|
bounds := rect.Intersect(frame.Bounds())
|
||||||
|
|
||||||
|
if bounds.Empty() {
|
||||||
|
return RegionAnalysis{
|
||||||
|
Name: region.Name,
|
||||||
|
DominantHSV: HSVColor{H: 0, S: 0, V: 0},
|
||||||
|
MatchPercent: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate dominant HSV by averaging all pixels in region
|
||||||
|
var totalH, totalS, totalV int64
|
||||||
|
var pixelCount int64
|
||||||
|
|
||||||
|
// Also count matches for the region's expected color range
|
||||||
|
var matchingPixels int64
|
||||||
|
expectedColorRange := s.getExpectedColorRange(region.Name)
|
||||||
|
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||||
|
c := frame.At(x, y)
|
||||||
|
hsv := vision.RGBToHSV(c)
|
||||||
|
|
||||||
|
totalH += int64(hsv.H)
|
||||||
|
totalS += int64(hsv.S)
|
||||||
|
totalV += int64(hsv.V)
|
||||||
|
pixelCount++
|
||||||
|
|
||||||
|
// Check if this pixel matches the expected color range
|
||||||
|
if expectedColorRange != nil && s.colorInRange(hsv, *expectedColorRange) {
|
||||||
|
matchingPixels++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dominantHSV := HSVColor{H: 0, S: 0, V: 0}
|
||||||
|
matchPercent := 0.0
|
||||||
|
|
||||||
|
if pixelCount > 0 {
|
||||||
|
dominantHSV = HSVColor{
|
||||||
|
H: int(totalH / pixelCount),
|
||||||
|
S: int(totalS / pixelCount),
|
||||||
|
V: int(totalV / pixelCount),
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedColorRange != nil {
|
||||||
|
matchPercent = float64(matchingPixels) / float64(pixelCount) * 100.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RegionAnalysis{
|
||||||
|
Name: region.Name,
|
||||||
|
DominantHSV: dominantHSV,
|
||||||
|
MatchPercent: matchPercent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getExpectedColorRange(regionName string) *vision.ColorRange {
|
||||||
|
// Get D2R config to determine expected color ranges for each region type
|
||||||
|
// For now, we'll handle the main regions: health_orb and mana_orb
|
||||||
|
switch regionName {
|
||||||
|
case "health_orb":
|
||||||
|
return &vision.ColorRange{
|
||||||
|
LowerH: 0, UpperH: 30,
|
||||||
|
LowerS: 50, UpperS: 255,
|
||||||
|
LowerV: 30, UpperV: 255,
|
||||||
|
}
|
||||||
|
case "mana_orb":
|
||||||
|
return &vision.ColorRange{
|
||||||
|
LowerH: 200, UpperH: 240,
|
||||||
|
LowerS: 40, UpperS: 255,
|
||||||
|
LowerV: 20, UpperV: 255,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// For other regions, we don't have specific expected color ranges
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) colorInRange(hsv vision.HSV, colorRange vision.ColorRange) bool {
|
||||||
|
return hsv.H >= colorRange.LowerH && hsv.H <= colorRange.UpperH &&
|
||||||
|
hsv.S >= colorRange.LowerS && hsv.S <= colorRange.UpperS &&
|
||||||
|
hsv.V >= colorRange.LowerV && hsv.V <= colorRange.UpperV
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) drawRegionOverlays(img *image.RGBA, regions []RegionInfo) {
|
func (s *Server) drawRegionOverlays(img *image.RGBA, regions []RegionInfo) {
|
||||||
// Simple colored rectangles for region overlays
|
// Simple colored rectangles for region overlays
|
||||||
colors := []image.Uniform{
|
colors := []image.Uniform{
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,11 @@ type Config struct {
|
||||||
func DefaultConfig() Config {
|
func DefaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Colors: Colors{
|
Colors: Colors{
|
||||||
// Updated ranges based on real D2R screenshot analysis
|
// Tightened HSV ranges based on real D2R screenshot calibration
|
||||||
// Health orb - includes the actual colors found (olive/brown when low, reds when high)
|
// Health orb is RED: H 0-30 (covering red wrapping), S 50-255, V 30-255
|
||||||
HealthFilled: HSVRange{0, 30, 10, 100, 255, 255}, // Wide range: reds through yellows/browns
|
HealthFilled: HSVRange{0, 50, 30, 30, 255, 255},
|
||||||
// Mana orb - includes the actual colors found (dark/brown when low, blues when high)
|
// Mana orb is BLUE: H 200-240, S 40-255, V 20-255
|
||||||
ManaFilled: HSVRange{40, 20, 10, 250, 255, 255}, // Browns through blues
|
ManaFilled: HSVRange{200, 40, 20, 240, 255, 255},
|
||||||
ItemUnique: HSVRange{15, 100, 180, 30, 255, 255},
|
ItemUnique: HSVRange{15, 100, 180, 30, 255, 255},
|
||||||
ItemSet: HSVRange{35, 100, 150, 55, 255, 255},
|
ItemSet: HSVRange{35, 100, 150, 55, 255, 255},
|
||||||
ItemRare: HSVRange{15, 50, 200, 25, 150, 255},
|
ItemRare: HSVRange{15, 50, 200, 25, 150, 255},
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,19 @@ func NewDetector(config Config, services plugin.EngineServices) *Detector {
|
||||||
// DetectState analyzes a screenshot and returns the current game state.
|
// DetectState analyzes a screenshot and returns the current game state.
|
||||||
func (d *Detector) DetectState(frame image.Image) plugin.GameState {
|
func (d *Detector) DetectState(frame image.Image) plugin.GameState {
|
||||||
// Priority-based detection:
|
// Priority-based detection:
|
||||||
// 1. Check for loading screen
|
// 1. Check for in-game first (health orb visible)
|
||||||
// 2. Check for main menu
|
// 2. Check for loading screen (but only if not in-game)
|
||||||
// 3. Check for character select
|
// 3. Check for main menu
|
||||||
// 4. Check for in-game (health orb visible)
|
// 4. Check for character select
|
||||||
// 5. Check for death screen
|
// 5. Check for death screen
|
||||||
|
|
||||||
|
if d.isInGame(frame) {
|
||||||
|
vitals := d.ReadVitals(frame)
|
||||||
|
if vitals.HealthPct <= 0.01 { // Consider very low health as dead
|
||||||
|
return plugin.StateDead
|
||||||
|
}
|
||||||
|
return plugin.StateInGame
|
||||||
|
}
|
||||||
if d.isLoading(frame) {
|
if d.isLoading(frame) {
|
||||||
return plugin.StateLoading
|
return plugin.StateLoading
|
||||||
}
|
}
|
||||||
|
|
@ -42,13 +49,6 @@ func (d *Detector) DetectState(frame image.Image) plugin.GameState {
|
||||||
if d.isCharacterSelect(frame) {
|
if d.isCharacterSelect(frame) {
|
||||||
return plugin.StateCharacterSelect
|
return plugin.StateCharacterSelect
|
||||||
}
|
}
|
||||||
if d.isInGame(frame) {
|
|
||||||
vitals := d.ReadVitals(frame)
|
|
||||||
if vitals.HealthPct <= 0.01 { // Consider very low health as dead
|
|
||||||
return plugin.StateDead
|
|
||||||
}
|
|
||||||
return plugin.StateInGame
|
|
||||||
}
|
|
||||||
return plugin.StateUnknown
|
return plugin.StateUnknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,8 +98,8 @@ func (d *Detector) IsInGame(frame image.Image) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Detector) isLoading(frame image.Image) bool {
|
func (d *Detector) isLoading(frame image.Image) bool {
|
||||||
// Check for loading screen by looking for mostly black screen
|
// Check for loading screen by looking for mostly black screen (>90%)
|
||||||
// This is a simple heuristic - could be improved
|
// AND ensuring no health orb colors are present in the health orb region
|
||||||
bounds := frame.Bounds()
|
bounds := frame.Bounds()
|
||||||
totalPixels := 0
|
totalPixels := 0
|
||||||
darkPixels := 0
|
darkPixels := 0
|
||||||
|
|
@ -125,8 +125,30 @@ func (d *Detector) isLoading(frame image.Image) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// If more than 70% of screen is dark, likely loading
|
// Require more than 90% dark screen
|
||||||
return float64(darkPixels)/float64(totalPixels) > 0.7
|
darkPercentage := float64(darkPixels) / float64(totalPixels)
|
||||||
|
if darkPercentage <= 0.9 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional check: ensure no health colors in health orb region
|
||||||
|
healthRegion := d.services.Region("health_orb")
|
||||||
|
if !healthRegion.Empty() {
|
||||||
|
healthColor := vision.ColorRange{
|
||||||
|
LowerH: d.config.Colors.HealthFilled.LowerH,
|
||||||
|
UpperH: d.config.Colors.HealthFilled.UpperH,
|
||||||
|
LowerS: d.config.Colors.HealthFilled.LowerS,
|
||||||
|
UpperS: d.config.Colors.HealthFilled.UpperS,
|
||||||
|
LowerV: d.config.Colors.HealthFilled.LowerV,
|
||||||
|
UpperV: d.config.Colors.HealthFilled.UpperV,
|
||||||
|
}
|
||||||
|
// If health colors are detected in health orb region, it's not loading
|
||||||
|
if d.vision.HasColorInRegion(frame, healthRegion, healthColor) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Detector) isMainMenu(frame image.Image) bool {
|
func (d *Detector) isMainMenu(frame image.Image) bool {
|
||||||
|
|
|
||||||
|
|
@ -539,16 +539,22 @@
|
||||||
// Update capture image with overlays
|
// Update capture image with overlays
|
||||||
async function updateCaptureImage() {
|
async function updateCaptureImage() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/capture/frame/annotated');
|
let url = '/api/capture/frame/annotated';
|
||||||
|
|
||||||
|
// Always pass regions param to control overlays (empty = no overlays)
|
||||||
|
const regionNames = Array.from(visibleRegions).join(',');
|
||||||
|
url += `?regions=${encodeURIComponent(regionNames)}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const imageUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
// Clean up previous URL
|
// Clean up previous URL
|
||||||
if (captureImage.src && captureImage.src.startsWith('blob:')) {
|
if (captureImage.src && captureImage.src.startsWith('blob:')) {
|
||||||
URL.revokeObjectURL(captureImage.src);
|
URL.revokeObjectURL(captureImage.src);
|
||||||
}
|
}
|
||||||
|
|
||||||
captureImage.src = url;
|
captureImage.src = imageUrl;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addLogLine('ERROR', `Failed to update capture image: ${error.message}`);
|
addLogLine('ERROR', `Failed to update capture image: ${error.message}`);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue