Skip to content

Commit 742c2fb

Browse files
[WIP] Unity Environment Registry (#3967)
* [WIP] Unity Environment Registry [JIRA ticket](https://jira.unity3d.com/browse/MLA-997) [Design Document](https://docs.google.com/document/d/1bFQ3_oXsA80FMou8kwqYxC53kqG5L3i0mbTQUH4shY4/edit#) In This PR : Prototype of the Unity Environment Registry Uploaded the 3DBall and Basic Environments for mac only How to use on Python : ```python from mlagents_envs.registry import UnityEnvRegistry registry = UnityEnvRegistry() print(registry["3DBall"].description) env = registry["3DBall"].make() env.reset() for i in range(10): print(i) env.step() env.close() ``` * Other approach: - UnityEnvRegistry is no longer static and needs to be instantiated - Providing a default_registry that will contains our environments - Added a functionality to register RemoteRegistryEntry with a yaml file * Some extra verification of the url : The binary will have a hash of the url in its name to make sure the right environment is opened and not just the one present. Useful if environments are renamed * Using logger rather than print for debug statement * More strict version of pyyaml * Added the additional argument option in the remote_registry_entry * preparing the registry for windows * Documentation and changelog * added a simple test * fixing test * no graphics in the test * [skip ci] Update docs/Unity-Environment-Registry.md Co-authored-by: andrewcoh <[email protected]> * addressing comments * changing the base port * [skip ci] Updating doc * Changing the test to not use the default_registry environments Co-authored-by: andrewcoh <[email protected]>
1 parent 6d354ae commit 742c2fb

File tree

9 files changed

+586
-0
lines changed

9 files changed

+586
-0
lines changed

com.unity.ml-agents/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to
1313
- `max_step` in the `TerminalStep` and `TerminalSteps` objects was renamed `interrupted`.
1414
- `beta` and `epsilon` in `PPO` are no longer decayed by default but follow the same schedule as learning rate. (#3940)
1515
- `get_behavior_names()` and `get_behavior_spec()` on UnityEnvironment were replaced by the `behavior_specs` property. (#3946)
16+
- The first version of the Unity Environment Registry (Experimental) has been released. More information [here](https://github.com/Unity-Technologies/ml-agents/blob/master/docs/Unity-Environment-Registry.md)(#3967)
1617
### Minor Changes
1718
#### com.unity.ml-agents (C#)
1819
- `ObservableAttribute` was added. Adding the attribute to fields or properties on an Agent will allow it to generate

docs/Unity-Environment-Registry.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Unity Environment Registry [Experimental]
2+
3+
The Unity Environment Registry is a database of pre-built Unity environments that can be easily used without having to install the Unity Editor. It is a great way to get started with our [UnityEnvironment API](Python-API.md).
4+
5+
## Loading an Environment from the Registry
6+
7+
To get started, you can access the default registry we provide with our [Example Environments](Learning-Environment-Examples.md). The Unity Environment Registry implements a _Mapping_, therefore, you can access an entry with its identifier with the square brackets `[ ]`. Use the following code to list all of the environment identifiers present in the default registry:
8+
9+
```python
10+
from mlagents_envs.registry import default_registry
11+
12+
environment_names = list(default_registry.keys())
13+
for name in environment_names:
14+
print(name)
15+
```
16+
17+
The `make()` method on a registry value will return a `UnityEnvironment` ready to be used. All arguments passed to the make method will be passed to the constructor of the `UnityEnvironment` as well. Refer to the documentation on the [Python-API](Python-API.md) for more information about the arguments of the `UnityEnvironment` constructor. For example, the following code will create the environment under the identifier `"my-env"`, reset it, perform a few steps and finally close it:
18+
19+
```python
20+
from mlagents_envs.registry import default_registry
21+
22+
env = default_registry["my-env"].make()
23+
env.reset()
24+
for _ in range(10):
25+
env.step()
26+
env.close()
27+
```
28+
29+
## Create and share your own registry
30+
31+
In order to share the `UnityEnvironemnt` you created, you must :
32+
- [Create a Unity executable](Learning-Environment-Executable.md) of your environment for each platform (Linux, OSX and/or Windows)
33+
- Place each executable in a `zip` compressed folder
34+
- Upload each zip file online to your preferred hosting platform
35+
- Create a `yaml` file that will contain the description and path to your environment
36+
- Upload the `yaml` file online
37+
The `yaml` file must have the following format :
38+
39+
```yaml
40+
environments:
41+
- <environment-identifier>:
42+
expected_reward: <expected-reward-float>
43+
description: <description-of-the-environment>
44+
linux_url: <url-to-the-linux-zip-folder>
45+
darwin_url: <url-to-the-osx-zip-folder>
46+
win_url: <url-to-the-windows-zip-folder>
47+
additional_args:
48+
- <an-optional-list-of-command-line-arguments-for-the-executable>
49+
- ...
50+
```
51+
52+
Your users can now use your environment with the following code :
53+
```python
54+
from mlagents_envs.registry import UnityEnvRegistry
55+
56+
registry = UnityEnvRegistry()
57+
registry.register_from_yaml("url-or-path-to-your-yaml-file")
58+
```
59+
__Note__: The `"url-or-path-to-your-yaml-file"` can be either a url or a local path.
60+
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from mlagents_envs.registry.unity_env_registry import ( # noqa F401
2+
default_registry,
3+
UnityEnvRegistry,
4+
)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from abc import abstractmethod
2+
from typing import Any, Optional
3+
from mlagents_envs.base_env import BaseEnv
4+
5+
6+
class BaseRegistryEntry:
7+
def __init__(
8+
self,
9+
identifier: str,
10+
expected_reward: Optional[float],
11+
description: Optional[str],
12+
):
13+
"""
14+
BaseRegistryEntry allows launching a Unity Environment with its make method.
15+
:param identifier: The name of the Unity Environment.
16+
:param expected_reward: The cumulative reward that an Agent must receive
17+
for the task to be considered solved.
18+
:param description: A description of the Unity Environment. Contains human
19+
readable information about potential special arguments that the make method can
20+
take as well as information regarding the observation, reward, actions,
21+
behaviors and number of agents in the Environment.
22+
"""
23+
self._identifier = identifier
24+
self._expected_reward = expected_reward
25+
self._description = description
26+
27+
@property
28+
def identifier(self) -> str:
29+
"""
30+
The unique identifier of the entry
31+
"""
32+
return self._identifier
33+
34+
@property
35+
def expected_reward(self) -> Optional[float]:
36+
"""
37+
The cumulative reward that an Agent must receive for the task to be considered
38+
solved.
39+
"""
40+
return self._expected_reward
41+
42+
@property
43+
def description(self) -> Optional[str]:
44+
"""
45+
A description of the Unity Environment the entry can make.
46+
"""
47+
return self._description
48+
49+
@abstractmethod
50+
def make(self, **kwargs: Any) -> BaseEnv:
51+
"""
52+
This method creates a Unity BaseEnv (usually a UnityEnvironment).
53+
"""
54+
raise NotImplementedError(
55+
f"The make() method not implemented for entry {self.identifier}"
56+
)
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import urllib.request
2+
import tempfile
3+
import os
4+
import uuid
5+
import shutil
6+
import glob
7+
import yaml
8+
import hashlib
9+
10+
from zipfile import ZipFile
11+
from sys import platform
12+
from typing import Tuple, Optional, Dict, Any
13+
14+
from mlagents_envs.logging_util import get_logger
15+
16+
logger = get_logger(__name__)
17+
18+
# The default logical block size is 8192 bytes (8 KB) for UFS file systems.
19+
BLOCK_SIZE = 8192
20+
21+
22+
def get_local_binary_path(name: str, url: str) -> str:
23+
"""
24+
Returns the path to the executable previously downloaded with the name argument. If
25+
None is found, the executable at the url argument will be downloaded and stored
26+
under name for future uses.
27+
:param name: The name that will be given to the folder containing the extracted data
28+
:param url: The URL of the zip file
29+
"""
30+
NUMBER_ATTEMPTS = 5
31+
path = get_local_binary_path_if_exists(name, url)
32+
if path is None:
33+
logger.debug(
34+
f"Local environment {name} not found, downloading environment from {url}"
35+
)
36+
for attempt in range(NUMBER_ATTEMPTS): # Perform 5 attempts at downloading the file
37+
if path is not None:
38+
break
39+
try:
40+
download_and_extract_zip(url, name)
41+
except IOError:
42+
logger.debug(
43+
f"Attempt {attempt + 1} / {NUMBER_ATTEMPTS} : Failed to download"
44+
)
45+
path = get_local_binary_path_if_exists(name, url)
46+
47+
if path is None:
48+
raise FileNotFoundError(
49+
f"Binary not found, make sure {url} is a valid url to "
50+
"a zip folder containing a valid Unity executable"
51+
)
52+
return path
53+
54+
55+
def get_local_binary_path_if_exists(name: str, url: str) -> Optional[str]:
56+
"""
57+
Recursively searches for a Unity executable in the extracted files folders. This is
58+
platform dependent : It will only return a Unity executable compatible with the
59+
computer's OS. If no executable is found, None will be returned.
60+
:param name: The name/identifier of the executable
61+
:param url: The url the executable was downloaded from (for verification)
62+
"""
63+
_, bin_dir = get_tmp_dir()
64+
extension = None
65+
66+
if platform == "linux" or platform == "linux2":
67+
extension = "*.x86_64"
68+
if platform == "darwin":
69+
extension = "*.app"
70+
if platform == "win32":
71+
extension = "*.exe"
72+
if extension is None:
73+
raise NotImplementedError("No extensions found for this platform.")
74+
url_hash = "-" + hashlib.md5(url.encode()).hexdigest()
75+
path = os.path.join(bin_dir, name + url_hash, "**", extension)
76+
candidates = glob.glob(path, recursive=True)
77+
if len(candidates) == 0:
78+
return None
79+
else:
80+
for c in candidates:
81+
# Unity sometimes produces another .exe file that we must filter out
82+
if "UnityCrashHandler64" not in c:
83+
return c
84+
return None
85+
86+
87+
def get_tmp_dir() -> Tuple[str, str]:
88+
"""
89+
Returns the path to the folder containing the downloaded zip files and the extracted
90+
binaries. If these folders do not exist, they will be created.
91+
:retrun: Tuple containing path to : (zip folder, extracted files folder)
92+
"""
93+
MLAGENTS = "ml-agents-binaries"
94+
TMP_FOLDER_NAME = "tmp"
95+
BINARY_FOLDER_NAME = "binaries"
96+
mla_directory = os.path.join(tempfile.gettempdir(), MLAGENTS)
97+
if not os.path.exists(mla_directory):
98+
os.makedirs(mla_directory)
99+
os.chmod(mla_directory, 16877)
100+
zip_directory = os.path.join(tempfile.gettempdir(), MLAGENTS, TMP_FOLDER_NAME)
101+
if not os.path.exists(zip_directory):
102+
os.makedirs(zip_directory)
103+
os.chmod(zip_directory, 16877)
104+
bin_directory = os.path.join(tempfile.gettempdir(), MLAGENTS, BINARY_FOLDER_NAME)
105+
if not os.path.exists(bin_directory):
106+
os.makedirs(bin_directory)
107+
os.chmod(bin_directory, 16877)
108+
return (zip_directory, bin_directory)
109+
110+
111+
def download_and_extract_zip(url: str, name: str) -> None:
112+
"""
113+
Downloads a zip file under a URL, extracts its contents into a folder with the name
114+
argument and gives chmod 755 to all the files it contains. Files are downloaded and
115+
extracted into special folders in the temp folder of the machine.
116+
:param url: The URL of the zip file
117+
:param name: The name that will be given to the folder containing the extracted data
118+
"""
119+
zip_dir, bin_dir = get_tmp_dir()
120+
url_hash = "-" + hashlib.md5(url.encode()).hexdigest()
121+
binary_path = os.path.join(bin_dir, name + url_hash)
122+
if os.path.exists(binary_path):
123+
shutil.rmtree(binary_path)
124+
125+
# Download zip
126+
try:
127+
request = urllib.request.urlopen(url, timeout=30)
128+
except urllib.error.HTTPError as e: # type: ignore
129+
e.msg += " " + url
130+
raise
131+
zip_size = int(request.headers["content-length"])
132+
zip_file_path = os.path.join(zip_dir, str(uuid.uuid4()) + ".zip")
133+
with open(zip_file_path, "wb") as zip_file:
134+
downloaded = 0
135+
while True:
136+
buffer = request.read(BLOCK_SIZE)
137+
if not buffer:
138+
# There is nothing more to read
139+
break
140+
downloaded += len(buffer)
141+
zip_file.write(buffer)
142+
downloaded_percent = downloaded / zip_size * 100
143+
print_progress(f" Downloading {name}", downloaded_percent)
144+
print("")
145+
146+
# Extraction
147+
with ZipFileWithProgress(zip_file_path, "r") as zip_ref:
148+
zip_ref.extract_zip(f" Extracting {name}", binary_path) # type: ignore
149+
print("")
150+
151+
# Clean up zip
152+
print_progress(f" Cleaning up {name}", 0)
153+
os.remove(zip_file_path)
154+
155+
# Give permission
156+
for f in glob.glob(binary_path + "/**/*", recursive=True):
157+
# 16877 is octal 40755, which denotes a directory with permissions 755
158+
os.chmod(f, 16877)
159+
print_progress(f" Cleaning up {name}", 100)
160+
print("")
161+
162+
163+
def print_progress(prefix: str, percent: float) -> None:
164+
"""
165+
Displays a single progress bar in the terminal with value percent.
166+
:param prefix: The string that will precede the progress bar.
167+
:param percent: The percent progression of the bar (min is 0, max is 100)
168+
"""
169+
BAR_LEN = 20
170+
percent = min(100, max(0, percent))
171+
bar_progress = min(int(percent / 100 * BAR_LEN), BAR_LEN)
172+
bar = "|" + "\u2588" * bar_progress + " " * (BAR_LEN - bar_progress) + "|"
173+
str_percent = "%3.0f%%" % percent
174+
print(f"{prefix} : {bar} {str_percent} \r", end="", flush=True)
175+
176+
177+
def load_remote_manifest(url: str) -> Dict[str, Any]:
178+
"""
179+
Converts a remote yaml file into a Python dictionary
180+
"""
181+
tmp_dir, _ = get_tmp_dir()
182+
try:
183+
request = urllib.request.urlopen(url, timeout=30)
184+
except urllib.error.HTTPError as e: # type: ignore
185+
e.msg += " " + url
186+
raise
187+
manifest_path = os.path.join(tmp_dir, str(uuid.uuid4()) + ".yaml")
188+
with open(manifest_path, "wb") as manifest:
189+
while True:
190+
buffer = request.read(BLOCK_SIZE)
191+
if not buffer:
192+
# There is nothing more to read
193+
break
194+
manifest.write(buffer)
195+
try:
196+
result = load_local_manifest(manifest_path)
197+
finally:
198+
os.remove(manifest_path)
199+
return result
200+
201+
202+
def load_local_manifest(path: str) -> Dict[str, Any]:
203+
"""
204+
Converts a local yaml file into a Python dictionary
205+
"""
206+
with open(path) as data_file:
207+
return yaml.safe_load(data_file)
208+
209+
210+
class ZipFileWithProgress(ZipFile):
211+
"""
212+
This is a helper class inheriting from ZipFile that allows to display a progress
213+
bar while the files are being extracted.
214+
"""
215+
216+
def extract_zip(self, prefix: str, path: str) -> None:
217+
members = self.namelist()
218+
path = os.fspath(path)
219+
total = len(members)
220+
n = 0
221+
for zipinfo in members:
222+
self.extract(zipinfo, path, None) # type: ignore
223+
n += 1
224+
print_progress(prefix, n / total * 100)

0 commit comments

Comments
 (0)