Jump to content

Scripting Distance to Map Topology / Features


Recommended Posts

Posted (edited)

First off, I want to acknowledge that what follows is not groundbreaking — I know others have achieved similar results before. But despite plenty of digging, I couldn’t find anywhere this specific approach is properly documented or explained. So I took on the challenge of figuring it out myself.

The Disposition function has recently been discovered and mentioned in the Reddit forums.  From what I can tell this does something similar and is far simpler. That said, I still think this method might still be useful to some in the community.

It’s also about time I gave something back, since everything I’ve managed to create for my own fun has been built on the shoulders of what others have shared before me.

What This Does

This setup provides a way to determine the distance from any given point to the nearest map feature: forested areas, roads, towns, rivers, beaches, and so on.  Once the KD-Tree is built during mission load, the actual search for nearest point is pretty much instant.  Building the KD-Tree can take a few minutes depending on the number of data points.

I'm sure there are lots of practical uses but I've used it primarily to spawn/move ground units without them ending up in the indestructable forest.

At its core, this is just a way to calculate the distance from a position to the closest matching map feature — based on colour-coded map data.

How It Works
Python (setup):

  1. Take a screenshot of the map and grab the RGB values for each pixel.
  2. Match each pixel’s RGB to map coordinates.
  3. Filter the pixels based on RGB — for example, greenish colours for trees.
  4. Save the resulting coordinate data to a file.
  5. Optionally reduce the data resolution to save performance.

Lua (in mission scripting):

  1. Load the pre-generated data file.
  2. Build a KD-Tree (a spatial data structure that allows fast searches).
  3. Use the KD-Tree to find the nearest matching point and measure the distance.
  4. Use this to determine proximity — e.g. “how far am I from the nearest tree?”

Final Notes
As must be obivous by now, I’m not a programmer. So feel free to take this and rewrite it to be cleaner, faster, or just less ugly and pass it on.
 

 

Lua Code to Setup the KD-Tree

--- ================= KD Tree Functions ================= ---
-- Function to load data from CSV file
local function LoadDataPointsTrees(filename)
    local points = {}
    local Path = "C:\\Users\\xbox\\Saved Games\\DCS\\Missions"
    local ok ,fileData = UTILS.LoadFromFile(Path,filename)
    for lineNumber = 1, #fileData do
        lineString=fileData[lineNumber]
        local easting, northing = lineString:match("([^,]+),([^,]+)")
        easting = tonumber(easting)
        northing = tonumber(northing)
        table.insert(points, {northing, easting})
    end
    return points
end

-- ===================== Data Point Distance Function ====================== --

-- Rebuild the KDTree recursively
function rebuild_kdtree(data)
    if data == nil then
        return nil
    end
    local point = data["point"]
    local left = rebuild_kdtree(data["left"])
    local right = rebuild_kdtree(data["right"])
    return KDTreeNode:new(point, left, right)
end

-- Define a k-d tree node
local KDTreeNode = {}
KDTreeNode.__index = KDTreeNode
function KDTreeNode:create(point, left, right)
    local node = {}
    setmetatable(node, KDTreeNode)
    node.point = point
    node.left = left
    node.right = right
    return node
end

-- Helper function to slice a table
local function sliceTable(tbl, startIdx, endIdx)
    local sliced = {}
    for i = startIdx, endIdx do
        table.insert(sliced, tbl[i])
    end
    return sliced
end

-- Function to build the k-d tree
local function buildKDTree(points, depth)
    if #points == 0 then
        return nil
    end
    -- Select axis based on depth
    local axis = depth % 2
    -- Sort point list and choose median as pivot element
    table.sort(points, function(a, b)
        if axis == 0 then
            return a[1] < b[1]  -- Compare x-coordinates
        else
            return a[2] < b[2]  -- Compare y-coordinates
        end
    end)
    -- Choose median
    local median = math.floor(#points / 2) + 1
    -- Create node and construct subtrees
    return KDTreeNode:create(
        points[median],
        buildKDTree(sliceTable(points, 1, median - 1), depth + 1),
        buildKDTree(sliceTable(points, median + 1, #points), depth + 1)
    )
end

-- Function to calculate squared distance between two points
local function squaredDistance(p1, p2)
    return ((p1[1] - p2[1])^2 + (p1[2] - p2[2])^2)
end

-- Nearest neighbor search in the k-d tree
local function nearestNeighborSearch(node, target, depth, best, bestDist)
    if node == nil then
        return best, bestDist
    end
    local axis = depth % 2
    local nextBest = best
    local nextBestDist = bestDist
    local currentDist = squaredDistance(node.point, target)
    if currentDist < bestDist then
        nextBest = node.point
        nextBestDist = currentDist
    end
    local direction
    if axis == 0 then
        direction = target[1] < node.point[1]
    else
        direction = target[2] < node.point[2]
    end
    local primary = direction and node.left or node.right
    local secondary = direction and node.right or node.left
    nextBest, nextBestDist = nearestNeighborSearch(primary, target, depth + 1, nextBest, nextBestDist)
    if (axis == 0 and (target[1] - node.point[1]) ^ 2 < nextBestDist) or
       (axis == 1 and (target[2] - node.point[2]) ^ 2 < nextBestDist) then
        nextBest, nextBestDist = nearestNeighborSearch(secondary, target, depth + 1, nextBest, nextBestDist)
    end
    return nextBest, nextBestDist
end

-- Function to print the k-d tree (for testing purposes)
local function printKDTree(node, depth)
    if node == nil then
        return
    end
    printKDTree(node.left, depth + 1)
    printKDTree(node.right, depth + 1)
end

-- Example function to serialize a Lua table (KD-tree) to a string
local function serializeTableFROMGPT(t, indent)
    indent = indent or 0
    local result = ""
    local padding = string.rep(" ", indent)

    result = result .. "{\n"
    for k, v in pairs(t) do
        local key = (type(k) == "string" and string.format("%q", k)) or k
        if type(v) == "table" then
            result = result .. padding .. "[" .. key .. "] = " .. serializeTable(v, indent + 4) .. ",\n"
        else
            local value = (type(v) == "string" and string.format("%q", v)) or tostring(v)
            result = result .. padding .. "[" .. key .. "] = " .. value .. ",\n"
        end
    end
    result = result .. string.rep(" ", indent) .. "}"
    return result
end

-- Example function to serialize a Lua table (KD-tree) to a string
local function serializeTable_close(t, indent)
    indent = indent or 0
    local result = ""
    local padding = string.rep(" ", indent)

    result = result .. "{\n"
    for k, v in pairs(t) do
        local key = (type(k) == "string" and string.format("%q", k)) or k
        if type(v) == "table" then
            result = result .. padding .. " " .. key .. " : " .. serializeTable(v, indent + 4) .. ",\n"
        else
            local value = (type(v) == "string" and string.format("%q", v)) or tostring(v)
            result = result .. padding .. " " .. key .. " : " .. value .. ",\n"
        end
    end
    result = result .. string.rep(" ", indent) .. "}"
    return result
end

-- Example function to serialize a Lua table (KD-tree) to a string
local function serializeTable(t, indent)
    indent = indent or 0
    local result = ""
    local padding = string.rep(" ", indent)

    result = result .. "{"
    for k, v in pairs(t) do
        local key = (type(k) == "string" and string.format("%q", k)) or k
        if type(v) == "table" then
            result = result .. padding .. " " .. key .. " = " .. serializeTable(v, indent + 0) .. ","
        else
            local value = (type(v) == "string" and string.format("%q", v)) or tostring(v)
            result = result .. padding .. " " .. key .. " = " .. value .. ","
        end
    end
    result = result .. string.rep(" ", indent) .. "}"
    return result
end

--  =============================================================================================================



local function GetDistanceToTrees(targetCoord)
    -- Nearest neighbor search example
    local distSquared    = nil
    local nearest = nil
    local north   = math.floor(targetCoord.x)
    local east    = math.floor(targetCoord.z)
    --env.info("       - target coordinate " .. north .. " ".. east)
    local target = {north, east}
    --env.info(" Test north "..target[1] .. " east ".. target[2])
    if kdtree then
        nearest, distSquared = nearestNeighborSearch(kdtree, target, 0, kdtree.point, squaredDistance(kdtree.point, target))
        --print(string.format("Nearest neighbor to (%d, %d) is (%d, %d) with squared distance %d", target[1], target[2], nearest[1], nearest[2], dist))
    else
        env.info("    ---- KD-Tree is empty ---")
    end
    -- Returning the squared distance (as finding root takes time)
    return distSquared  
end


– Set up the KD-Tree
env.info("      -- KD-Tree:")
env.info("       - Loading the RAW Tree Datapoints")
local points = LoadDataPointsTrees(treeDataPoints)
env.info("       - Building the KD Tree")
kdtree = buildKDTree(points, 0)    

 

To get a distance use the following:

– Using the functions :
distanceToTrees = GetDistanceToTrees(randomCoordinate)

 

Before building the KD-Tree, you need to generate the RGB data for each coordinate on the map that is of interest.

The python scripts follow:

The 1stPython script captures the map data coordinates and RGB for each location.

The 2nd Python script filters the RGB into suitabl buckets, that you can use in the KD-Tree

The 3rd Python script can be used to reduce the number of data points and thus the time taken to build the KD-tree when first running a mission.

# -- ========================================== --
# -- ============ DCS Tree Mapping ============ --
# -- ============    Script One    ============ --
# -- ========================================== --
#
# -- Mikey, "LittleNose"
# -- October 2024
#
# Script to take screenshots from DCS Map and determine the terrain / features
# For me it is primarily used by my mission script to determine location of the indestructable DCS trees 
# This can be used as a database for searching against when spawning / moving ground units
#
# I use the data points file to check coordinates for distances from trees
# The datafile takes a long time to read into DCS script, however, it only needs to do it once per server restart.
# Within the DCS mission script, KD-Trees is the function I use for searching the data points file
# https://www.baeldung.com/cs/k-d-trees
# 
# 
# Thanks go out to the Martin from the SDCS server.
# Here on the ED Forums, it is mentioned SDCS use "Anti Tree Technology":
# https://forum.dcs.world/topic/346202-strategic-dcs-a-modern-persistent-pvp-combined-arms-driven-campaign/
# Although I don't know how they do it, knowing it's possible was the inspiration to try.
# In the same vein, I hope that this spurs others into refining and helping the community.
#
# 
# -- =============== Instructions =============== --
#
# Check your Python has the required libraries, and pip those you don't (eg: pip install pyautogui).
# Open DCS in windowed mode, position the window top left of your screen
# Open Python client in non-overlapping window
# Open Mission Editor in DCS, and run a blank mission. 
# Move the compass rose out of the way
# Set the script vaiables below
#       - coordinates top left and bottom right for the area wanted on the map
#       - the size per pixel (30m to 70m works well)
# Run this python script - don't touch the mouse/keyboard until complete
# Your mouse pointer will move around and teh screen will likely zoom and pan too, be patient.
#
# The csv file created is a list of coordinates and RGB values at those coordinates
#
#
#
#
# ---------------------------------------------------------------- GLOBAL USER VARIABLES -----------

# Pixel size in m.  30-70 seems to work best.
targetPixelSize = 30

# Specify the top left and bottom right coordinates of the area you want to capture
# Keeping this to a smaller area reduces time in capturign data, but also when starting up the KD-Tree in mission

#   SYRIA : COMPLETE      X vert, Z horiz
#targetMapCoordTopLeft  = [ 300000, -500000]
#targetMapCoordBtmRight = [-375000,  435000]

#   SYRIA : CYPRUS       X vert, Z horiz
targetMapCoordTopLeft  = [ 81500, -332000]
targetMapCoordBtmRight = [-40000, -114000]

#   SYRIA : CENTRAL       X vert, Z horiz
#targetMapCoordTopLeft  = [ 23155, -67000]
#targetMapCoordBtmRight = [-187000, 253000]



# Change this to match your visible map area (avoid the borders etc)
xWindowLocations = [60,  2400]  # left, right     60, 2400
yWindowLocations = [80,  1300]  # top, bottom     100, 1300

# ---------------------------------------------------------------- END OF USER VARIABLES -----------


# ---------------------------------------------------------------- SYSTEM VARIABLES ----------------
workingFolder           = "C:\\Users\\xbox\\Saved Games\\DCSTrees\\"
rgbPartOutputFilePrefix = "rgb_part_"
rgbAllOutputFilename    = "rgb_all.csv"

# Distance between pixels on the screen
distPerPixelTopX   = 0
distPerPixelBtmX   = 0
distPerPixelLeftY  = 0
distPerPixelRightY = 0

xCornerCoords = [[0,0],[0,0],[0,0],[0,0]]
zCornerCoords = [[0,0],[0,0],[0,0],[0,0]]

# ---------------------------------------------------------------- SYSTEM IMPORTS ----------------
import os
import csv
import math

# pip install pandas
import pandas as pd
import numpy as np
np.set_printoptions(suppress=True)

#pip install matplotlib
import matplotlib.pyplot as plt

#pip install pyautogui
import pyautogui

#pip install keyboard
import keyboard

#pip install pyperclip
import pyperclip

#pip install pynput
from pynput.mouse import Controller
mouse = Controller()




# ---------------------------------------------------------------- FUNCTIONS ----------------

def GetAreaGameCoordinates():
    #print(" - Get Coordinates of the DCS Screen Grab")
    global xCornerCoords, zCornerCoords
    # loop 4 corners
    cornerIndices = [[0,0],[1,0],[0,1],[1,1]]
    for i in range(len(cornerIndices)):
        ix = cornerIndices[i][0]
        iy = cornerIndices[i][1]
        # Move Mouse to corner location of screen grab
        pyautogui.moveTo(xWindowLocations[ix],yWindowLocations[iy])
        # Get Popup with coords
        keyboard.press('alt')
        pyautogui.click()
        keyboard.release('alt')
        # copy the coordinates
        pyautogui.moveTo(430, 375)
        pyautogui.click()
        # close the window
        pyautogui.moveTo(350, 375)
        pyautogui.click()
        # pu thte text into the clipboard
        copiedText = pyperclip.paste().split("\n")[0]

        #print("   - Text copied: {}".format(copiedText))

        # Manipulate teh text to get teh coordinate values
        copiedTextSplit =  copiedText.split(" ")
        if "+" in copiedTextSplit[1]:        
            xCornerCoords[i]=(int(copiedTextSplit[1].split("+")[1]))
        elif "-" in copiedTextSplit[1]:        
            xCornerCoords[i]=(int("-"+copiedTextSplit[1].split("-")[1]))

        if "+" in copiedTextSplit[2]:        
            zCornerCoords[i]=(int(copiedTextSplit[2].split("+")[1]))
        elif "-" in copiedTextSplit[2]:        
            zCornerCoords[i]=(int("-"+copiedTextSplit[2].split("-")[1]))





def GetPixelSize():
    print("\n - Get the pixel size")
    global distPerPixelTopX, distPerPixelBtmX, distPerPixelLeftY, distPerPixelRightY
    print("                      Northing   Easting")
    print("   - top left coord  : {},    {}".format(xCornerCoords[0], zCornerCoords[0]))
    print("   - top right coord : {},    {}".format(xCornerCoords[1], zCornerCoords[1]))
    print("   - btm left coord  : {},    {}".format(xCornerCoords[2], zCornerCoords[2]))
    print("   - btm right coord : {},    {}".format(xCornerCoords[3], zCornerCoords[3]))
    # coordinates
    NorthingDistanceLeft  = xCornerCoords[2] - xCornerCoords[0]
    NorthingDistanceRight = xCornerCoords[3] - xCornerCoords[1]
    EastingDistanceTop    = zCornerCoords[1] - zCornerCoords[0]
    EastingDistanceBtm    = zCornerCoords[3] - zCornerCoords[2]

    distPerPixelTopX   = EastingDistanceTop / pixelsWidth
    distPerPixelBtmX   = EastingDistanceBtm / pixelsWidth
    distPerPixelLeftY  = NorthingDistanceLeft / pixelsHeight
    distPerPixelRightY = NorthingDistanceRight / pixelsHeight
    print("   - Size of a Pixel Top   X: {:.2f} m".format(distPerPixelTopX))
    print("   - Size of a Pixel Btm   X: {:.2f} m".format(distPerPixelBtmX))
    print("   - Size of a Pixel Left  Y: {:.2f} m".format(distPerPixelLeftY))  
    print("   - Size of a Pixel Right Y: {:.2f} m\n".format(distPerPixelRightY))




def GrabScreen():
    print(" - Grabbing Screen ")

    left   = xWindowLocations[0]
    top    = yWindowLocations[0]
    width  = xWindowLocations[1] - xWindowLocations[0] +1
    height = yWindowLocations[1] - yWindowLocations[0] +1
    #Grab image:
    imageScreen = pyautogui.screenshot(region=(left,top, width,height))
    print("  - Captured Screen, pixels x{},y{} to x{}, y{}".format(xWindowLocations[0], yWindowLocations[0], xWindowLocations[1], yWindowLocations[1]))
    # Generate a List of the RGB values in format (r,g,b,transparency):
    pixel_values = list(imageScreen.getdata())
    rgbPartOutputFilename = rgbPartOutputFilePrefix+str(screenX)+"_"+str(screenY)+".csv"
    try:
        os.remove(rgbPartOutputFilename)
        print("   - Deleting Existing file {}".format(rgbPartOutputFilename))
    except:
        pass
        print("   - Existing file {} not found to delete".format(rgbPartOutputFilename))

    fileOpenCSV = open(rgbPartOutputFilename, "a")
    #Save RGB to a file:
    print("  - Generating RGB {}, X{} Y{}".format(loopNumber, screenX, screenY))
    eastingPixelCoord  = 0
    northingPixelCoord = 0 
    for i in range(len(pixel_values)):
        pixelColourUnit = [0,0,0]
        pixelColourUnit[0] = round(pixel_values[i][0], 3)
        pixelColourUnit[1] = round(pixel_values[i][1], 3)
        pixelColourUnit[2] = round(pixel_values[i][2], 3)
        pix_x = i % width
        pix_y = int((i-pix_x)/width)

        eastingPixelCoord  = zCornerCoords[0] + (pix_x * distPerPixelTopX)
        northingPixelCoord = xCornerCoords[0] + (pix_y * distPerPixelLeftY)

        dataString    =   str(pix_x) + ", " + str(pix_y) + ", " + str(eastingPixelCoord) + ", " + str(northingPixelCoord) +",  "+ str(pixelColourUnit[0])+ ", " +  str(pixelColourUnit[1])+ ", " +  str(pixelColourUnit[2]) + "\n"   
        fileOpenCSV.write(dataString)
    print("  - Screen Coordinate Top Left  N{:.1f}, E{:.1f}".format((xCornerCoords[0]), (zCornerCoords[0])))
    print("  - Screen Coordinate Btm Right N{:.1f}, E{:.1f}".format(northingPixelCoord, eastingPixelCoord))
    
    fileOpenCSV.close()
    print(" - Saved CSV File {}, {}\n".format(loopNumber, rgbPartOutputFilename))





def MoveTheMap():
    print(" - Moving the Map for the Next Screen Grab {} {}".format(screenX, screenY))
    global xCornerCoords, zCornerCoords
    pixelX1  = (xWindowLocations[0]+xWindowLocations[1])/2
    pixelY1  = (yWindowLocations[0]+yWindowLocations[1])/2
    pixelX2  = (xWindowLocations[0]+xWindowLocations[1])/2
    pixelY2  = (yWindowLocations[0]+yWindowLocations[1])/2
    if screenX == (xSections - 1):
        pixelsMoveY = -(pixelsHeight + 1)
        pixelY1 = yWindowLocations[1]
        pixelY2 = yWindowLocations[0]
        print("   - Moving the Map Screen South, {} pixels, from {} to {}".format(pixelsMoveY, pixelY1, pixelY2))
    else:
        if screenY%2 == 0:  # then it is even numbered row (row 0 = even)
            pixelsMoveX = pixelsWidth + 1
            pixelX1 = xWindowLocations[1]
            pixelX2 = xWindowLocations[0]
            print("   - Moving the Map Screen East, {} pixels, from {} to {}".format(pixelsMoveX, pixelX1, pixelX2))
        else: # even numbered row
            pixelsMoveX = -(pixelsWidth + 1)
            pixelX1 = xWindowLocations[0]
            pixelX2 = xWindowLocations[1]
            print("   - Moving the Map Screen West, {} pixels, from {} to {}".format(pixelsMoveX, pixelX1, pixelX2))


    pyautogui.moveTo(pixelX1, pixelY1)
    pyautogui.mouseDown(button='left')
    pyautogui.moveTo(pixelX2, pixelY2, 1.5)
    pyautogui.mouseUp(button='left')

    print("  - Finished moving the map")

def CombineTheFiles():
    print("\n---------=== Combining the separate files into one ===---------")
    try:
        print("  - Deleting Existing Combined data file {}".format(rgbAllOutputFilename))
        os.remove(rgbAllOutputFilename)
    except:
        print("  - No Existing file found to delete")
    print("  - Combinging Files:")
    for i in range(xSections*ySections):
        fileX = i % xSections
        fileY = int((i-fileX) / xSections)
        rgbPartOutputFilename = rgbPartOutputFilePrefix+str(fileX)+"_"+str(fileY)+".csv"
        print("      {}: {}".format(i+1, rgbPartOutputFilename))
        fileCombined = open(rgbAllOutputFilename, 'a+') # append
        fileToAdd    = open(rgbPartOutputFilename, 'r') # read
        fileCombined.write(fileToAdd.read())            # combine
        fileToAdd.close()
        fileCombined.close()
    print("  - Completed Aggregating the Files into {}".format(rgbAllOutputFilename))


# ---------------------------------------------------------------- MAIN ----------------
print("\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n")
print("\n\n\n\n\n\n\n\n\n---------=== Screen Grabber and RGB Pixelator ===--------- \n")
# BASIC Setup
os.chdir(workingFolder)
# pixels
pixelsWidth  = xWindowLocations[1] - xWindowLocations[0]
pixelsHeight = yWindowLocations[1] - yWindowLocations[0]

# ------------------------------- NEW CODE

def GetCurrentZoom():
    GetAreaGameCoordinates()
    print("   - Image Grab Size, Pixels, X: {}, Y: {}".format(pixelsWidth+1, pixelsHeight+1))
    GetPixelSize()
    print("   - Image Grab Size, Coords, Top Left : {}, {}".format(xCornerCoords[0], zCornerCoords[0]))
    print("   - Image Grab Size, Coords, Btm Right: {}, {}".format(xCornerCoords[3], zCornerCoords[3]))

#pyautogui.scroll(-25)
for i in range(10):
    GetCurrentZoom()
    averagePixelSize     = (distPerPixelTopX + distPerPixelBtmX - distPerPixelLeftY - distPerPixelRightY)/4
    ratioToTargtetPixels = targetPixelSize / averagePixelSize
    print("     Target Size: {}, Average: {:.2f}, Pixel Ratio: {:.3f}".format(targetPixelSize, averagePixelSize, ratioToTargtetPixels))
    if averagePixelSize < 0.95 * targetPixelSize:
        print(" --- Zoom Out,  Factor: {:.3f}".format(ratioToTargtetPixels))
        mouse.scroll(0, -(2*ratioToTargtetPixels))
    elif averagePixelSize > 1.05 * targetPixelSize:
        print(" --- Zoom In,  Factor: {:.3f}".format(ratioToTargtetPixels))
        mouse.scroll(0, 2/ratioToTargtetPixels)
    else:
        print("  -- Target Pixel size found")
        break

def MoveTheMapToStartPoint():
    print("\n  -- Moving the Map to the Start Location {} {}".format(targetMapCoordTopLeft[0], targetMapCoordTopLeft[1]))    
    targetX  = targetMapCoordTopLeft[0]
    targetZ  = targetMapCoordTopLeft[1]
    print("   - Target Locations:   targetX {},  targetZ {}".format(targetX, targetZ))
    GetAreaGameCoordinates()
    currentX = xCornerCoords[0]
    currentZ = zCornerCoords[0]
    print("   - Current Locations: currentX {}, currentZ {}".format(currentX, currentZ))
    pixelSize = averagePixelSize    
    targetDeltaX = (targetX - currentX)
    targetDeltaZ = (targetZ - currentZ)
    print("   - Target Delta         DeltaX {:.1f}, DeltaZ {:.1f}".format(targetDeltaX, targetDeltaZ))
    targetDeltaPixelsY = (targetX - currentX)/pixelSize
    targetDeltaPixelsX = (targetZ - currentZ)/pixelSize
    print("   - Target Delta Pixels  DeltaX {:.1f}, DeltaY {:.1f}".format(targetDeltaPixelsX, targetDeltaPixelsY))


    deltaXMultiple = abs(targetDeltaPixelsX / (xWindowLocations[1]-xWindowLocations[0]))
    deltaYMultiple = abs(targetDeltaPixelsY / (yWindowLocations[1]-yWindowLocations[0]))

    if targetDeltaPixelsX > 0:
        startPixelX  = xWindowLocations[1]
        fullPixelX   = xWindowLocations[0]
    else:
        startPixelX  = xWindowLocations[0]
        fullPixelX   = xWindowLocations[1]

    if targetDeltaPixelsY > 0:
        startPixelY  = yWindowLocations[0]
        fullPixelY   = yWindowLocations[1]
    else:
        startPixelY  = yWindowLocations[1]
        fullPixelY   = yWindowLocations[0]

    print("   - Pixels Start - X {:.1f}, Y {:.1f}".format(startPixelX, startPixelY))
    print("   - Pixels Full  - X {:.1f}, Y {:.1f}".format(fullPixelX, fullPixelY))

    for i in range(math.floor(deltaXMultiple)):
        pyautogui.moveTo(startPixelX, startPixelY)
        pyautogui.mouseDown(button='left')
        pyautogui.moveTo(fullPixelX, startPixelY, 1.5)
        pyautogui.mouseUp(button='left')

    for j in range(math.floor(deltaYMultiple)):
        pyautogui.moveTo(startPixelX, startPixelY)
        pyautogui.mouseDown(button='left')
        pyautogui.moveTo(startPixelX, fullPixelY, 1.5)
        pyautogui.mouseUp(button='left')

    GetAreaGameCoordinates()
    currentX = xCornerCoords[0]
    currentZ = zCornerCoords[0]
    print("   - Current Locations: currentX {}, currentZ {}".format(currentX, currentZ))
    pixelSize = averagePixelSize    
    targetDeltaX = (targetX - currentX)
    targetDeltaZ = (targetZ - currentZ)
    print("   - Target Delta         DeltaX {:.1f}, DeltaZ {:.1f}".format(targetDeltaX, targetDeltaZ))
    targetDeltaPixelsY = (targetX - currentX)/pixelSize
    targetDeltaPixelsX = (targetZ - currentZ)/pixelSize
    print("   - Target Delta Pixels  DeltaX {:.1f}, DeltaY {:.1f}".format(targetDeltaPixelsX, targetDeltaPixelsY))

    if targetDeltaPixelsX > 0:
        deltaPixelsX = min(targetDeltaPixelsX, (xWindowLocations[1]-xWindowLocations[0]))
        startPixelX  = xWindowLocations[1]
        endPixelX    = startPixelX - deltaPixelsX
    else:
        deltaPixelsX = max(targetDeltaPixelsX, -(xWindowLocations[1]-xWindowLocations[0]))
        startPixelX  = xWindowLocations[0]
        endPixelX    = startPixelX - deltaPixelsX
    if targetDeltaPixelsY > 0:
        deltaPixelsY = min(targetDeltaPixelsY, (yWindowLocations[1]-yWindowLocations[0]))
        startPixelY  = yWindowLocations[0]
        endPixelY    = startPixelY + deltaPixelsY
    else:
        deltaPixelsY = max(targetDeltaPixelsY, -(yWindowLocations[1]-yWindowLocations[0]))
        startPixelY  = yWindowLocations[1]
        endPixelY    = startPixelY + deltaPixelsY
    print("   - deltaPixelsX : {:.1f}".format(deltaPixelsX))
    print("   - deltaPixelsY : {:.1f}".format(deltaPixelsY))
    print("   - Moving the Map by DeltaX {:.1f}, DeltaY {:.1f}".format(deltaPixelsX, deltaPixelsY))

    pyautogui.moveTo(startPixelX, startPixelY)
    pyautogui.mouseDown(button='left')
    pyautogui.moveTo(endPixelX, endPixelY, 1.5)
    pyautogui.mouseUp(button='left')  
    GetAreaGameCoordinates()
    print("   - Moved the map towards start point")

reqdMapWidth  = targetMapCoordTopLeft[1] - targetMapCoordBtmRight[1]
reqdMapHeight = targetMapCoordTopLeft[0] - targetMapCoordBtmRight[0]
imageWidth    = zCornerCoords[3] - zCornerCoords[0]
imageHeight   = xCornerCoords[0] - xCornerCoords[3]
print("\n  -- Required Width {:.1f},\t Height {:.1f}".format(reqdMapWidth, reqdMapHeight))
print("  -- Image    Width {:.1f},\t Height {:.1f}".format(imageWidth, imageHeight))
reqdSectionsHorizontal = abs(reqdMapWidth  / imageWidth)
reqdSectionsVertical   = abs(reqdMapHeight / imageHeight)
xSections = math.ceil(reqdSectionsHorizontal)
ySections = math.ceil(reqdSectionsVertical)

# Set these to unity for testing a single screen cap
#xSections = 1
#ySections = 1

numberOfSections = xSections * ySections

print("  -- Required Snapshots from screen Horiz: {:.1f}, Vert: {:.1f}".format(reqdSectionsHorizontal, reqdSectionsVertical))
print("       Actual Snapshots from screen Horiz: {:.1f}, Vert: {:.1f}".format(xSections, ySections))

MoveTheMapToStartPoint()


# ------------------------------------- END OF NEW SECTION -----------
# -- MAIN LOOP --
for loopNumber in range(numberOfSections):
    # variables to know the screen indexed location
    screenX = loopNumber % xSections
    screenY = int((loopNumber-screenX) / xSections)
    print("\n---------=== New Screen Grab, Index {} {} ===---------".format(screenX, screenY))
    # run the functions
    GetAreaGameCoordinates()
    GetPixelSize()
    GrabScreen()
    if loopNumber != numberOfSections-1:
        MoveTheMap()
    else:
        print(" - Last Screen Complete, no need to move the map")

# Gather all rgb files and gather into one file
CombineTheFiles()
# Filter the file, and generate a "Trees" file
#FilterForTrees()


print("  - Image Grab Size, Pixels, X: {}, Y: {}".format(pixelsWidth+1, pixelsHeight+1))
print("  - {} Images, Pixels, X: {}, Y: {}".format(numberOfSections, numberOfSections*(pixelsWidth+1), numberOfSections*(pixelsHeight+1)))
print("  - Total Number of Pixels: {}".format(numberOfSections*(pixelsWidth+1) *(pixelsHeight+1)))

print("\n ------ === Script Complete === ------\n\n")

 

Second Python Script to Filter the data

# -- ========================================== --
# -- ============ DCS Tree Mapping ============ --
# -- ============    Script Two    ============ --
# -- ========================================== --
#
# -- Mikey, "LittleNose"
# -- July 2025


plotBuckets = False

import os
import csv
import math

workingFolder           = "C:\\Users\\xbox\\Saved Games\\DCSTrees\\"
os.chdir(workingFolder)
rgbAllOutputFilename    = "rgb_all.csv"

outputFilenameWater     = "filtered_water.csv"
outputFilenameRiver     = "filtered_river.csv"
outputFilenameTrees     = "filtered_trees.csv"
outputFilenameTowns     = "filtered_towns.csv"
outputFilenameRoads     = "filtered_roads.csv"
outputFilenameLand      = "filtered_land.csv"

import os
import csv
import math
import numpy as np
import matplotlib.pyplot as plt

def FilterForRGB(valR, valG, valB, fileNameOut, tolRange, tolDiff,
                 bins=8, top_n=20, show_plot=True, save_plot=None,
                 rgbAllOutputFilename="rgb_all.csv"):

    print(f"\n---------=== Filtering for RGB ===--------- {fileNameOut}")
    matchCount = 0
    matched_colors = []  # collect (r, g, b) for matched rows

    try:
        print(f"  - Deleting existing file {fileNameOut}")
        os.remove(fileNameOut)
    except FileNotFoundError:
        print("  - No existing file found to delete")

    print("  - Filtering...")
    with open(rgbAllOutputFilename, 'r', newline='') as infile, \
         open(fileNameOut, 'w', newline='') as outfile:
        reader = csv.reader(infile)
        writer = csv.writer(outfile)

        for row in reader:
            try:
                east, north = round(float(row[2])), round(float(row[3]))
                r, g, b = float(row[4]), float(row[5]), float(row[6])
            except (IndexError, ValueError):
                # skip malformed rows
                continue

            # Euclidean distance in RGB space
            distance = math.sqrt((r - valR)**2 + (g - valG)**2 + (b - valB)**2)

            if distance <= tolRange:
                absoluteRG = abs(r - g)  # equivalent to sqrt((r-g)^2)
                if absoluteRG < tolDiff:
                    writer.writerow([east, north])
                    matched_colors.append((r, g, b))
                    matchCount += 1

    print(f"  - {matchCount} points found in Filtered data in file: {fileNameOut}\n")

    if plotBuckets == True:

        if matchCount == 0:
            print("  - No matches; skipping histogram/plot.")
            return

        # Prepare bins: allow scalar or per-channel
        if isinstance(bins, int):
            bins_tuple = (bins, bins, bins)
        else:
            try:
                bins_tuple = tuple(bins)
                if len(bins_tuple) != 3:
                    raise ValueError
            except Exception:
                raise ValueError("bins must be int or iterable of three ints")

        colors_arr = np.array(matched_colors)  # shape (N, 3)

        # Define edges assuming RGB in [0, 255]; adjust if your scale differs
        edges = [np.linspace(0, 255, num=b+1) for b in bins_tuple]

        # 3D histogram
        hist, edges_out = np.histogramdd(colors_arr, bins=edges)

        # Flatten and get top N buckets
        flat_hist = hist.flatten()
        nonzero_idxs = np.nonzero(flat_hist)[0]
        if nonzero_idxs.size == 0:
            print("  - No nonzero buckets despite matches; unexpected. Aborting plot.")
            return

        top_n = min(top_n, nonzero_idxs.size)
        top_indices = np.argsort(flat_hist[nonzero_idxs])[::-1][:top_n]
        selected_flat_idxs = nonzero_idxs[top_indices]
        counts = flat_hist[selected_flat_idxs]

        # Compute the representative (center) color for each selected bucket
        bucket_shape = hist.shape  # (bins_r, bins_g, bins_b)
        centers = []
        for flat_idx in selected_flat_idxs:
            r_idx, g_idx, b_idx = np.unravel_index(flat_idx, bucket_shape)
            # compute center of each bin
            r_center = (edges[0][r_idx] + edges[0][r_idx + 1]) / 2
            g_center = (edges[1][g_idx] + edges[1][g_idx + 1]) / 2
            b_center = (edges[2][b_idx] + edges[2][b_idx + 1]) / 2
            centers.append((r_center, g_center, b_center))

        # Build bar chart
        fig, ax = plt.subplots(figsize=(max(6, top_n * 0.3), 4))
        bar_positions = np.arange(len(counts))
        bar_labels = [f"#{i+1}" for i in range(len(counts))]  # simple labels

        # Normalize colors to [0,1] for matplotlib
        bar_colors = [(r/255, g/255, b/255) for r, g, b in centers]

        bars = ax.bar(bar_positions, counts, color=bar_colors, edgecolor='black')
        ax.set_xticks(bar_positions)
        ax.set_xticklabels(bar_labels, rotation=45, ha='right')
        ax.set_ylabel("Count in Bucket")
        ax.set_title(f"Top {len(counts)} RGB Buckets Passing Filter")

        # Annotate with hex color and count
        for idx, bar in enumerate(bars):
            r, g, b = centers[idx]
            rgb_label = f"({int(r)},{int(g)},{int(b)})"
            ax.text(bar.get_x() + bar.get_width() / 2,
                    bar.get_height(),
                    f"{int(counts[idx])}\n{rgb_label}",
                    ha='center', va='bottom', fontsize=7)

        plt.tight_layout()

        if save_plot:
            try:
                fig.savefig(save_plot, dpi=150)
                print(f"  - Saved histogram plot to {save_plot}")
            except Exception as e:
                print(f"  - Failed to save plot: {e}")

        if show_plot:
            plt.show()

        plt.close(fig)




# Caucasus Filters
#RGBWater  = [126, 185, 198]
#RGBRiver  = [ 55,  92, 140]
#RGBTrees  = [ 90, 138,  93]
#RGBTowns  = [198, 131,   0]  
#RGBRoads  = [170,  80,   0]

# Syria Filters
RGBWater  = [47,  143, 143]
RGBRiver  = [50,  160, 220]
RGBTrees  = [120, 135,  60]
RGBTowns  = [250, 154,  61]  
RGBRoads  = [143,  60,  30]
RGBLand   = [220, 111,  45]

tolRGBWater = [ 20, 100]
tolRGBRiver = [100, 999]
tolRGBTrees = [ 75,  10]
tolRGBTowns = [ 40,  75]
tolRGBRoads = [ 30, 200]
tolRGBLand  = [175,  75]

FilterForRGB(RGBWater[0], RGBWater[1], RGBWater[2], outputFilenameWater, tolRGBWater[0], tolRGBWater[1])
FilterForRGB(RGBRiver[0], RGBRiver[1], RGBRiver[2], outputFilenameRiver, tolRGBRiver[0], tolRGBRiver[1])
FilterForRGB(RGBTrees[0], RGBTrees[1], RGBTrees[2], outputFilenameTrees, tolRGBTrees[0], tolRGBTrees[1])
FilterForRGB(RGBTowns[0], RGBTowns[1], RGBTowns[2], outputFilenameTowns, tolRGBTowns[0], tolRGBTowns[1])
FilterForRGB(RGBRoads[0], RGBRoads[1], RGBRoads[2], outputFilenameRoads, tolRGBRoads[0], tolRGBRoads[1])
FilterForRGB( RGBLand[0],  RGBLand[1],  RGBLand[2], outputFilenameLand,   tolRGBLand[0],  tolRGBLand[1])

 

 

Finally, once you capture your data points, you can reduce the filesize by changing the resolution.  I find 75m data points works reasonably well.

# -- ========================================== --
# -- ============ DCS Tree Mapping ============ --
# -- ============   Script Three   ============ --
# -- ========================================== --

import os
import pandas as pd
import matplotlib.pyplot as plt


workingFolder           = "C:\\Users\\xbox\\Saved Games\\DCSTrees\\"
os.chdir(workingFolder)

# Load data from input file
#input_file = "filtered_water.csv"
#output_file = "filtered_water_reduced.csv"
#input_file = "filtered_river.csv"
#output_file = "filtered_river_reduced.csv"
#input_file = "filtered_trees.csv"
#output_file = "filtered_trees_reduced.csv"
#input_file = "filtered_towns.csv"
#output_file = "filtered_towns_reduced.csv"
input_file = "filtered_roads.csv"
output_file = "filtered_roads_reduced.csv"
grid_size = 75  # Adjust for more/less accuracy vs reduction

# Read the original CSV
data = pd.read_csv(input_file, header=None, names=["x", "y"])

# Dictionary to store one point per grid cell
grid_dict = {}

# Grid-based filtering
for x, y in data.itertuples(index=False):
    gx = x // grid_size
    gy = y // grid_size
    key = (gx, gy)
    if key not in grid_dict:
        grid_dict[key] = (x, y)

# Create reduced DataFrame
reduced_df = pd.DataFrame(list(grid_dict.values()), columns=["x", "y"])

# Save to output file
reduced_df.to_csv(output_file, index=False, header=False)


# Step 4: Plot both original and reduced datasets side-by-side
fig, axes = plt.subplots(1, 2, figsize=(14, 6), sharex=True, sharey=True)

# Original data plot
axes[0].scatter(data["x"], data["y"], s=1)
axes[0].set_title(f"Original Data ({len(data)} points)")
axes[0].set_xlabel("X")
axes[0].set_ylabel("Y")
axes[0].grid(True)

# Reduced data plot
axes[1].scatter(reduced_df["x"], reduced_df["y"], s=1, color='orange')
axes[1].set_title(f"Reduced Data ({len(reduced_df)} points)")
axes[1].set_xlabel("X")
axes[1].grid(True)

plt.suptitle("Original vs Reduced Data Points", fontsize=14)
plt.tight_layout(rect=[0, 0, 1, 0.96])
plt.show()

 

Hope you have fun.

 

Here's an example of the Rivers in Caucasus at 100m intervals.

null

image.png

 

Edited:

Updated Python script.  Now the zooming, start location, and number of screen captures are automatic (1st Python script), and the filtering in the 2nd Python script has more controls added, plus it can present the colour "buckets" that can be an aid to working out the filters to use.

Edited by LittleNose
Updated Python Scripting.
  • Like 2
Posted

I'm not sure what can be done with the data, people come up with extraordinary things sometimes.  Hopefully it helps someone do something they were previously struggling with.

I've updated it today to make the map capture script much easier to use.  It will now auto-zoom and capture the desired area.

  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...