brandonlanexyz commited on
Commit
44be440
·
verified ·
1 Parent(s): 181371b

Initial upload of Dualist Othello Game UI

Browse files
Files changed (7) hide show
  1. app.py +225 -0
  2. bitboard.py +81 -0
  3. dtypes.py +23 -0
  4. dualist_model.pth +3 -0
  5. game.py +88 -0
  6. model.py +72 -0
  7. requirements.txt +3 -0
app.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import torch
3
+ import numpy as np
4
+ from model import OthelloNet
5
+ from bitboard import get_bit, make_input_planes, bit_to_row_col
6
+ from game import generate_moves, apply_move, get_initial_board, count_pieces
7
+
8
+ # Load Dualist Model
9
+ def load_model():
10
+ model = OthelloNet(num_res_blocks=10, num_channels=256)
11
+ try:
12
+ checkpoint = torch.load("dualist_model.pth", map_location="cpu")
13
+ if "model_state_dict" in checkpoint:
14
+ model.load_state_dict(checkpoint["model_state_dict"])
15
+ else:
16
+ model.load_state_dict(checkpoint)
17
+ model.eval()
18
+ return model
19
+ except Exception as e:
20
+ print(f"Error loading model: {e}")
21
+ return None
22
+
23
+ DUALIST = load_model()
24
+
25
+ # Game State Helpers
26
+ def board_to_html(black_bb, white_bb, legal_moves_bb):
27
+ html = '<div class="board-container">'
28
+ for r in range(8):
29
+ html += '<div class="row">'
30
+ for c in range(8):
31
+ mask = get_bit(r, c)
32
+ cell_class = "cell"
33
+ content = ""
34
+
35
+ if black_bb & mask:
36
+ cell_class += " black-piece"
37
+ content = '<div class="piece black"></div>'
38
+ elif white_bb & mask:
39
+ cell_class += " white-piece"
40
+ content = '<div class="piece white"></div>'
41
+ elif legal_moves_bb & mask:
42
+ cell_class += " legal-move"
43
+ # This makes cells clickable in Gradio (with some custom JS)
44
+ content = f'<div class="hint" onclick="window.makeMove({r}, {c})"></div>'
45
+
46
+ html += f'<div class="{cell_class}" data-row="{r}" data-col="{c}">{content}</div>'
47
+ html += '</div>'
48
+ html += '</div>'
49
+ return html
50
+
51
+ class OthelloGame:
52
+ def __init__(self):
53
+ self.black_bb, self.white_bb = get_initial_board()
54
+ self.current_player = 1 # 1 for Black, -1 for White
55
+ self.game_over = False
56
+ self.history = []
57
+
58
+ def get_state(self):
59
+ player_bb = self.black_bb if self.current_player == 1 else self.white_bb
60
+ opponent_bb = self.white_bb if self.current_player == 1 else self.black_bb
61
+ legal_moves = generate_moves(player_bb, opponent_bb)
62
+ return player_bb, opponent_bb, legal_moves
63
+
64
+ def step(self, row, col):
65
+ if self.game_over: return self.render()
66
+
67
+ player_bb, opponent_bb, legal_moves = self.get_state()
68
+ move_mask = get_bit(row, col)
69
+
70
+ if not (legal_moves & move_mask):
71
+ return self.render() # Invalid move
72
+
73
+ # 1. Apply Move
74
+ new_player, new_opponent = apply_move(player_bb, opponent_bb, move_mask)
75
+
76
+ if self.current_player == 1:
77
+ self.black_bb, self.white_bb = new_player, new_opponent
78
+ else:
79
+ self.white_bb, self.black_bb = new_player, new_opponent
80
+
81
+ # 2. Switch Turn
82
+ self.current_player *= -1
83
+ self.check_skips()
84
+
85
+ # 3. If it's AI's turn (White), move automatically
86
+ if not self.game_over and self.current_player == -1:
87
+ self.ai_move()
88
+
89
+ return self.render()
90
+
91
+ def check_skips(self):
92
+ """Logic to handle passing turns if no moves are available."""
93
+ p_bb, o_bb, moves = self.get_state()
94
+ if moves == 0:
95
+ # Current player can't move, skip to next
96
+ self.current_player *= -1
97
+ p_bb, o_bb, next_moves = self.get_state()
98
+ if next_moves == 0:
99
+ self.game_over = True # Neither can move
100
+
101
+ def ai_move(self):
102
+ if self.game_over or DUALIST is None: return
103
+
104
+ p_bb, o_bb, moves = self.get_state()
105
+ if moves == 0:
106
+ self.current_player *= -1
107
+ return
108
+
109
+ # Inference
110
+ input_tensor = make_input_planes(p_bb, o_bb).to("cpu")
111
+ with torch.no_grad():
112
+ policy, _ = DUALIST(input_tensor)
113
+
114
+ probs = torch.exp(policy).squeeze(0).cpu().numpy()
115
+
116
+ best_idx = -1
117
+ max_p = -1
118
+ for i in range(64):
119
+ r, c = (63 - i) // 8, (63 - i) % 8
120
+ mask = get_bit(r, c)
121
+ if (moves & mask) and probs[i] > max_p:
122
+ max_p = probs[i]
123
+ best_idx = i
124
+
125
+ if best_idx != -1:
126
+ r, c = (63 - best_idx) // 8, (63 - best_idx) % 8
127
+ new_p, new_o = apply_move(p_bb, o_bb, get_bit(r, c))
128
+ self.white_bb, self.black_bb = new_p, new_o
129
+
130
+ self.current_player *= -1
131
+ self.check_skips()
132
+
133
+ def render(self):
134
+ p_bb, o_bb, moves = self.get_state()
135
+ board_html = board_to_html(self.black_bb, self.white_bb, moves)
136
+ b_count = bin(self.black_bb).count('1')
137
+ w_count = bin(self.white_bb).count('1')
138
+
139
+ status = f"### Score: Black {b_count} - White {w_count}"
140
+ if self.game_over:
141
+ winner = "Black wins!" if b_count > w_count else "White wins!" if w_count > b_count else "Draw!"
142
+ status += f"
143
+ ## GAME OVER: {winner}"
144
+ else:
145
+ turn = "Black's Turn" if self.current_player == 1 else "Dualist AI's Turn..."
146
+ status += f"
147
+ ## {turn}"
148
+
149
+ return board_html, status
150
+
151
+ # Instantiate Game
152
+ GAME = OthelloGame()
153
+
154
+ # CSS for Dark Mode/Cyberpunk aesthetic
155
+ custom_css = """
156
+ body, .gradio-container { background-color: #0a0a0c !important; color: #e0e0e0 !important; }
157
+ .board-container {
158
+ display: inline-block; background: #1a1a1e; padding: 10px; border-radius: 8px;
159
+ box-shadow: 0 0 20px rgba(0, 255, 157, 0.1); border: 1px solid #333;
160
+ }
161
+ .row { display: flex; }
162
+ .cell {
163
+ width: 50px; height: 50px; background: #2c3e50; border: 1px solid #1a1a1a;
164
+ display: flex; align-items: center; justify-content: center; position: relative;
165
+ cursor: default; transition: background 0.2s;
166
+ }
167
+ .cell:hover { background: #34495e; }
168
+ .black-piece { background: #2c3e50; }
169
+ .white-piece { background: #2c3e50; }
170
+ .piece { width: 40px; height: 40px; border-radius: 50%; box-shadow: 2px 2px 5px rgba(0,0,0,0.5); }
171
+ .black { background: #111; border: 2px solid #333; }
172
+ .white { background: #eee; border: 2px solid #ccc; }
173
+ .legal-move { cursor: pointer; }
174
+ .hint {
175
+ width: 12px; height: 12px; background: rgba(0, 255, 157, 0.4);
176
+ border-radius: 50%; border: 1px solid #00ff9d;
177
+ }
178
+ .hint:hover { transform: scale(1.5); background: rgba(0, 255, 157, 0.8); }
179
+ h1, h2, h3 { color: #00ff9d !important; text-shadow: 0 0 5px rgba(0,255,157,0.5); }
180
+ """
181
+
182
+ def handle_click(evt: gr.SelectData):
183
+ # This captures board clicks from the HTML if we can map it
184
+ # But for Gradio we can use a simpler approach: Buttons or hidden state
185
+ pass
186
+
187
+ def reset_game():
188
+ global GAME
189
+ GAME = OthelloGame()
190
+ return GAME.render()
191
+
192
+ def make_move_direct(coord_str):
193
+ try:
194
+ r, c = map(int, coord_str.split(','))
195
+ return GAME.step(r, c)
196
+ except:
197
+ return GAME.render()
198
+
199
+ with gr.Blocks(css=custom_css, title="Dualist Othello AI") as demo:
200
+ gr.Markdown("# 🌌 DUALIST OTHELLO AI")
201
+ gr.Markdown("Your first Neural Network opponent. Trained with Edax Grandmaster Teacher.")
202
+
203
+ with gr.Row():
204
+ with gr.Column(scale=2):
205
+ board_display = gr.HTML(GAME.render()[0])
206
+ with gr.Column(scale=1):
207
+ status_display = gr.Markdown(GAME.render()[1])
208
+ reset_btn = gr.Button("Reset Game", variant="secondary")
209
+
210
+ gr.Markdown("### How to play")
211
+ gr.Markdown("Click on the coordinates below to make your move (Black).")
212
+ # Gradio workaround for clickable HTML: Buttons for now
213
+ coords = []
214
+ for r in range(8):
215
+ for c in range(8):
216
+ coords.append(f"{r},{c}")
217
+
218
+ move_input = gr.Dropdown(label="Select Coordinates (Row, Col)", choices=coords, interactive=True)
219
+ submit_btn = gr.Button("Play Move", variant="primary")
220
+
221
+ submit_btn.click(make_move_direct, inputs=[move_input], outputs=[board_display, status_display])
222
+ reset_btn.click(reset_game, outputs=[board_display, status_display])
223
+
224
+ if __name__ == "__main__":
225
+ demo.launch()
bitboard.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+
4
+ # Bitboard Constants
5
+ BOARD_SIZE = 8
6
+ FULL_MASK = 0xFFFFFFFFFFFFFFFF
7
+
8
+ def popcount(x):
9
+ """Counts set bits in a 64-bit integer."""
10
+ return bin(x).count('1')
11
+
12
+ def bit_to_row_col(bit_mask):
13
+ """Converts a single bit mask to (row, col) coordinates."""
14
+ if bit_mask == 0:
15
+ return -1, -1
16
+ # Find the index of the set bit (0-63)
17
+ # Assumes only one bit is set
18
+ idx = bit_mask.bit_length() - 1
19
+ # Edax/Othello usually maps MSB to A1 (0,0) or LSB to H8 (7,7)
20
+ # Let's align with Edax: A1 is usually high bit.
21
+ # Standard: index 63 is A1, index 0 is H8.
22
+ # row = (63 - idx) // 8
23
+ # col = (63 - idx) % 8
24
+ # However, standard bit manipulation often uses LSB=0.
25
+ # Let's check Edax conventions later, but for now standard math:
26
+ row = (63 - idx) // 8
27
+ col = (63 - idx) % 8
28
+ return row, col
29
+
30
+ def get_bit(row, col):
31
+ """Returns a bitmask with a single bit set at (row, col)."""
32
+ shift = 63 - (row * 8 + col)
33
+ return 1 << shift
34
+
35
+ def make_input_planes(player_bb, opponent_bb):
36
+ """
37
+ Converts bitboards into a 3x8x8 input tensor for the Neural Network.
38
+ Plane 0: Player pieces (1 if present, 0 otherwise)
39
+ Plane 1: Opponent pieces (1 if present, 0 otherwise)
40
+ Plane 2: Constant 1 (indicating it's the player's turn, or generally providing board usage context)
41
+ Some implementations use 'Valid Moves' here instead.
42
+ Let's use a constant plane for now as per AlphaZero standard,
43
+ or we can update to valid moves if we have them handy.
44
+ """
45
+ planes = np.zeros((3, 8, 8), dtype=np.float32)
46
+
47
+ # Fill Plane 0 (Player)
48
+ for r in range(8):
49
+ for c in range(8):
50
+ mask = get_bit(r, c)
51
+ if player_bb & mask:
52
+ planes[0, r, c] = 1.0
53
+
54
+ # Fill Plane 1 (Opponent)
55
+ for r in range(8):
56
+ for c in range(8):
57
+ mask = get_bit(r, c)
58
+ if opponent_bb & mask:
59
+ planes[1, r, c] = 1.0
60
+
61
+ # Fill Plane 2 (Constant / Color)
62
+ # Often for single-network (canonical form), this might just be 1s.
63
+ planes[2, :, :] = 1.0
64
+
65
+ import torch
66
+ return torch.tensor(planes).unsqueeze(0) # Add batch dimension: (1, 3, 8, 8)
67
+
68
+ def print_board(black_bb, white_bb):
69
+ """Prints the board state using B/W symbols."""
70
+ print(" A B C D E F G H")
71
+ for r in range(8):
72
+ line = f"{r+1} "
73
+ for c in range(8):
74
+ mask = get_bit(r, c)
75
+ if black_bb & mask:
76
+ line += "B "
77
+ elif white_bb & mask:
78
+ line += "W "
79
+ else:
80
+ line += ". "
81
+ print(line)
dtypes.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import NamedTuple
2
+ import numpy as np
3
+
4
+ class Experience(NamedTuple):
5
+ """
6
+ Represents a single training example from self-play.
7
+
8
+ Attributes:
9
+ state (np.ndarray): The board state (canonical form), typically 3x8x8 (Player, Opponent, Valid/Turn).
10
+ policy (np.ndarray): The MCTS visit counts or probability distribution (size 65).
11
+ value (float): The final game outcome from the perspective of the player (1 for win, -1 for loss, 0 for draw).
12
+ """
13
+ state: np.ndarray
14
+ policy: np.ndarray
15
+ value: float
16
+
17
+ class GameResult(NamedTuple):
18
+ """
19
+ Represents the final outcome of a game.
20
+ """
21
+ final_board: np.ndarray
22
+ winner: int # 1 for Black, -1 for White, 0 for Draw
23
+ score_diff: int # Black score - White score
dualist_model.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4f2b4cfc68e08a211dbe1c95841d3cca181e0f66f1b80e9f7dc06ebc3e9bdaa3
3
+ size 47452382
game.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from src.bitboard import get_bit, bit_to_row_col, popcount
3
+
4
+ class OthelloGame:
5
+ def __init__(self):
6
+ # Initial Board Setup (A1 = MSB, H8 = LSB)
7
+ # Black pieces: D5 (35), E4 (28) -> 0x0000000810000000
8
+ # White pieces: D4 (36), E5 (27) -> 0x0000001008000000
9
+ self.player_bb = 0x0000000810000000 # Black starts
10
+ self.opponent_bb = 0x0000001008000000
11
+ self.turn = 1 # 1: Black, -1: White
12
+
13
+ def get_valid_moves(self, player, opponent):
14
+ """Calculates valid moves for 'player' against 'opponent'."""
15
+ empty = ~(player | opponent) & 0xFFFFFFFFFFFFFFFF
16
+
17
+ # Consistent with MSB=A1:
18
+ # North: << 8. South: >> 8.
19
+ # West: << 1 (mask A). East: >> 1 (mask H).
20
+ mask_h = 0x0101010101010101
21
+ mask_a = 0x8080808080808080
22
+
23
+ # Directions
24
+ shifts = [
25
+ (lambda x: (x & ~mask_h) >> 1), # East
26
+ (lambda x: (x & ~mask_a) << 1), # West
27
+ (lambda x: (x << 8) & 0xFFFFFFFFFFFFFFFF), # North
28
+ (lambda x: (x >> 8) & 0xFFFFFFFFFFFFFFFF), # South
29
+ (lambda x: (x & ~mask_h) << 7), # NE (N+E -> <<8 + >>1 = <<7)
30
+ (lambda x: (x & ~mask_a) << 9), # NW (N+W -> <<8 + <<1 = <<9)
31
+ (lambda x: (x & ~mask_h) >> 9), # SE (S+E -> >>8 + >>1 = >>9)
32
+ (lambda x: (x & ~mask_a) >> 7) # SW (S+W -> >>8 + <<1 = >>7)
33
+ ]
34
+
35
+ valid_moves = 0
36
+ for shift_func in shifts:
37
+ candidates = shift_func(player) & opponent
38
+ for _ in range(6): # Max 6 opponent pieces can be in between
39
+ candidates |= shift_func(candidates) & opponent
40
+ valid_moves |= shift_func(candidates) & empty
41
+
42
+ return valid_moves
43
+
44
+ def apply_move(self, player, opponent, move_bit):
45
+ """Calculates new boards after move_bit."""
46
+ if move_bit == 0:
47
+ return player, opponent
48
+
49
+ flipped = 0
50
+ mask_h = 0x0101010101010101
51
+ mask_a = 0x8080808080808080
52
+
53
+ shifts = [
54
+ (lambda x: (x & ~mask_h) >> 1), # East
55
+ (lambda x: (x & ~mask_a) << 1), # West
56
+ (lambda x: (x << 8) & 0xFFFFFFFFFFFFFFFF), # North
57
+ (lambda x: (x >> 8) & 0xFFFFFFFFFFFFFFFF), # South
58
+ (lambda x: (x & ~mask_h) << 7), # NE
59
+ (lambda x: (x & ~mask_a) << 9), # NW
60
+ (lambda x: (x & ~mask_h) >> 9), # SE
61
+ (lambda x: (x & ~mask_a) >> 7) # SW
62
+ ]
63
+
64
+ for shift_func in shifts:
65
+ mask = shift_func(move_bit)
66
+ potential_flips = 0
67
+ while mask & opponent:
68
+ potential_flips |= mask
69
+ mask = shift_func(mask)
70
+ if mask & player:
71
+ flipped |= potential_flips
72
+
73
+ new_player = player | move_bit | flipped
74
+ new_opponent = opponent & ~flipped
75
+ return new_player, new_opponent
76
+
77
+ def play_move(self, move_bit):
78
+ if move_bit != 0:
79
+ self.player_bb, self.opponent_bb = self.apply_move(self.player_bb, self.opponent_bb, move_bit)
80
+
81
+ # Turn always swaps (even on pass)
82
+ self.player_bb, self.opponent_bb = self.opponent_bb, self.player_bb
83
+ self.turn *= -1
84
+
85
+ def is_terminal(self):
86
+ p_moves = self.get_valid_moves(self.player_bb, self.opponent_bb)
87
+ o_moves = self.get_valid_moves(self.opponent_bb, self.player_bb)
88
+ return (p_moves == 0) and (o_moves == 0)
model.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import torch.nn.functional as F
4
+
5
+ class ResidualBlock(nn.Module):
6
+ def __init__(self, channels):
7
+ super(ResidualBlock, self).__init__()
8
+ self.conv1 = nn.Conv2d(channels, channels, kernel_size=3, padding=1, bias=False)
9
+ self.bn1 = nn.BatchNorm2d(channels)
10
+ self.conv2 = nn.Conv2d(channels, channels, kernel_size=3, padding=1, bias=False)
11
+ self.bn2 = nn.BatchNorm2d(channels)
12
+
13
+ def forward(self, x):
14
+ residual = x
15
+ out = F.relu(self.bn1(self.conv1(x)))
16
+ out = self.bn2(self.conv2(out))
17
+ out += residual
18
+ out = F.relu(out)
19
+ return out
20
+
21
+ class OthelloNet(nn.Module):
22
+ def __init__(self, num_res_blocks=10, num_channels=256):
23
+ super(OthelloNet, self).__init__()
24
+
25
+ # Input: 3 channels (Player pieces, Opponent pieces, Legal moves/Constant plane)
26
+ self.conv_input = nn.Conv2d(3, num_channels, kernel_size=3, padding=1, bias=False)
27
+ self.bn_input = nn.BatchNorm2d(num_channels)
28
+
29
+ # Residual Tower
30
+ self.res_blocks = nn.ModuleList([
31
+ ResidualBlock(num_channels) for _ in range(num_res_blocks)
32
+ ])
33
+
34
+ # Policy Head
35
+ self.policy_conv = nn.Conv2d(num_channels, 2, kernel_size=1, bias=False)
36
+ self.policy_bn = nn.BatchNorm2d(2)
37
+ # 2 channels * 8 * 8 = 128
38
+ self.policy_fc = nn.Linear(128, 65) # 64 squares + pass
39
+
40
+ # Value Head
41
+ self.value_conv = nn.Conv2d(num_channels, 1, kernel_size=1, bias=False)
42
+ self.value_bn = nn.BatchNorm2d(1)
43
+ # 1 channel * 8 * 8 = 64
44
+ self.value_fc1 = nn.Linear(64, 256)
45
+ self.value_fc2 = nn.Linear(256, 1)
46
+
47
+ def forward(self, x):
48
+ # Input Convolution
49
+ x = F.relu(self.bn_input(self.conv_input(x)))
50
+
51
+ # Residual Tower
52
+ for block in self.res_blocks:
53
+ x = block(x)
54
+
55
+ # Policy Head
56
+ p = F.relu(self.policy_bn(self.policy_conv(x)))
57
+ p = p.view(p.size(0), -1) # Flatten
58
+ p = self.policy_fc(p)
59
+ # We return logits (unnormalized), let loss function handle softma separation
60
+ # Or return log_softmax for NLLLoss if needed.
61
+ # Often for alpha zero implementations, returning log_softmax for training stability is good
62
+ # But here let's stick to returning raw logits (or log_softmax)
63
+ # Let's return log_softmax as it is numerically stable for KLDivLoss
64
+ p = F.log_softmax(p, dim=1)
65
+
66
+ # Value Head
67
+ v = F.relu(self.value_bn(self.value_conv(x)))
68
+ v = v.view(v.size(0), -1) # Flatten
69
+ v = F.relu(self.value_fc1(v))
70
+ v = torch.tanh(self.value_fc2(v))
71
+
72
+ return p, v
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio
2
+ torch
3
+ numpy