Spaces:
Running on Zero
Running on Zero
maze update
Browse files- app.py +98 -8
- requirements.txt +7 -0
- style_20250314.css +1 -1
- utils/constants.py +29 -4
- utils/file_utils.py +20 -1
- utils/image_utils.py +96 -2
- utils/mazlib/README.md +169 -0
- utils/mazlib/__init__.py +34 -0
- utils/mazlib/cli.py +231 -0
- utils/mazlib/draw.py +255 -0
- utils/mazlib/grid.py +139 -0
- utils/mazlib/hex.py +104 -0
- utils/mazlib/requirements.txt +1 -0
- utils/mazlib/tri.py +138 -0
- utils/storage.md +71 -11
- utils/storage.py +257 -19
app.py
CHANGED
|
@@ -37,12 +37,9 @@ import gc
|
|
| 37 |
# Import functions from modules
|
| 38 |
from utils.file_utils import (
|
| 39 |
cleanup_temp_files,
|
| 40 |
-
get_file_parts
|
|
|
|
| 41 |
)
|
| 42 |
-
from utils.storage import (
|
| 43 |
-
generate_permalink_from_urls,
|
| 44 |
-
upload_files_to_repo,
|
| 45 |
-
)
|
| 46 |
|
| 47 |
from utils.color_utils import (
|
| 48 |
hex_to_rgb,
|
|
@@ -119,16 +116,18 @@ from diffusers import (
|
|
| 119 |
FluxFillPipeline,
|
| 120 |
DiffusionPipeline,
|
| 121 |
AutoencoderTiny,
|
| 122 |
-
AutoencoderKL
|
|
|
|
| 123 |
)
|
| 124 |
|
| 125 |
PIPELINE_CLASSES = {
|
| 126 |
"FluxPipeline": FluxPipeline,
|
| 127 |
"FluxImg2ImgPipeline": FluxImg2ImgPipeline,
|
| 128 |
"FluxControlPipeline": FluxControlPipeline,
|
| 129 |
-
"FluxFillPipeline": FluxFillPipeline
|
|
|
|
| 130 |
}
|
| 131 |
-
|
| 132 |
import utils.version_info as version_info
|
| 133 |
|
| 134 |
#from utils.depth_estimation import (get_depth_map_from_state)
|
|
@@ -819,6 +818,79 @@ def replace_with_sketch_image(sketch_image, replace_current_lut_example_img: boo
|
|
| 819 |
if replace_current_lut_example_img:
|
| 820 |
current_lut_example_img = sketch
|
| 821 |
return sketch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 822 |
################################################################## DEPTH ESTIMATION ###############################################################################
|
| 823 |
|
| 824 |
@spaces.GPU(progress=gr.Progress(track_tqdm=True))
|
|
@@ -1794,6 +1866,24 @@ with gr.Blocks(title=title, delete_cache=(21600,86400)) as hexaGrid:
|
|
| 1794 |
outputs=[input_image], scroll_to_output=True
|
| 1795 |
)
|
| 1796 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1797 |
##################### model #######################################
|
| 1798 |
model_textbox.change(
|
| 1799 |
fn=update_prompt_notes,
|
|
|
|
| 37 |
# Import functions from modules
|
| 38 |
from utils.file_utils import (
|
| 39 |
cleanup_temp_files,
|
| 40 |
+
get_file_parts,
|
| 41 |
+
generate_permalink_from_urls
|
| 42 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
from utils.color_utils import (
|
| 45 |
hex_to_rgb,
|
|
|
|
| 116 |
FluxFillPipeline,
|
| 117 |
DiffusionPipeline,
|
| 118 |
AutoencoderTiny,
|
| 119 |
+
AutoencoderKL,
|
| 120 |
+
Flux2Pipeline, AutoModel
|
| 121 |
)
|
| 122 |
|
| 123 |
PIPELINE_CLASSES = {
|
| 124 |
"FluxPipeline": FluxPipeline,
|
| 125 |
"FluxImg2ImgPipeline": FluxImg2ImgPipeline,
|
| 126 |
"FluxControlPipeline": FluxControlPipeline,
|
| 127 |
+
"FluxFillPipeline": FluxFillPipeline,
|
| 128 |
+
"Flux2Pipeline": Flux2Pipeline
|
| 129 |
}
|
| 130 |
+
from utils.mazlib.cli import run_maze as run_maze_maker
|
| 131 |
import utils.version_info as version_info
|
| 132 |
|
| 133 |
#from utils.depth_estimation import (get_depth_map_from_state)
|
|
|
|
| 818 |
if replace_current_lut_example_img:
|
| 819 |
current_lut_example_img = sketch
|
| 820 |
return sketch
|
| 821 |
+
|
| 822 |
+
def update_maze_maker_shape_visibility(shape):
|
| 823 |
+
shape_key = (shape or "rect").lower()
|
| 824 |
+
is_hex = shape_key in ("hex", "hexagon", "hexagonal")
|
| 825 |
+
# show width/height for non-hex, show radius for hex
|
| 826 |
+
return (
|
| 827 |
+
gr.update(visible=not is_hex),
|
| 828 |
+
gr.update(visible=not is_hex),
|
| 829 |
+
gr.update(visible=is_hex),
|
| 830 |
+
)
|
| 831 |
+
|
| 832 |
+
|
| 833 |
+
def generate_and_apply_maze_to_input(shape, width, height, radius, cell_size, use_iterative, show_solution, show_doors, input_image, opacity_percent):
|
| 834 |
+
shape_key = (shape or "rect").lower()
|
| 835 |
+
|
| 836 |
+
if shape_key in ("hex", "hexagon", "hexagonal"):
|
| 837 |
+
normalized_size = max(1, int(radius))
|
| 838 |
+
else:
|
| 839 |
+
w = max(5, int(width))
|
| 840 |
+
h = max(5, int(height))
|
| 841 |
+
if w % 2 == 0:
|
| 842 |
+
w += 1
|
| 843 |
+
if h % 2 == 0:
|
| 844 |
+
h += 1
|
| 845 |
+
normalized_size = (w, h)
|
| 846 |
+
|
| 847 |
+
with NamedTemporaryFile(delete=False, suffix=".png", prefix="maze_maker_") as tmp_maze:
|
| 848 |
+
maze_output_path = tmp_maze.name
|
| 849 |
+
|
| 850 |
+
try:
|
| 851 |
+
_, solution_path = run_maze_maker(
|
| 852 |
+
shape=shape_key,
|
| 853 |
+
size=normalized_size,
|
| 854 |
+
cell=max(4, int(cell_size)),
|
| 855 |
+
use_iterative=bool(use_iterative),
|
| 856 |
+
draw=True,
|
| 857 |
+
output=maze_output_path,
|
| 858 |
+
show_solution=bool(show_solution),
|
| 859 |
+
show_doors=bool(show_doors),
|
| 860 |
+
)
|
| 861 |
+
constants.temp_files.append(maze_output_path)
|
| 862 |
+
except Exception as ex:
|
| 863 |
+
raise gr.Error(f"Failed to generate maze: {ex}")
|
| 864 |
+
|
| 865 |
+
path_info = f"path nodes: {len(solution_path)}" if solution_path else "no solution path found"
|
| 866 |
+
status = f"Maze created ({shape_key}, size={normalized_size}, {path_info}) and applied to Input Image."
|
| 867 |
+
|
| 868 |
+
# If there's no input image provided, return the generated maze image path
|
| 869 |
+
# (returning a file path avoids Gradio creating temporary PIL files which
|
| 870 |
+
# previously led to intermittent FileNotFoundError on chained calls).
|
| 871 |
+
if not input_image:
|
| 872 |
+
return maze_output_path, status
|
| 873 |
+
|
| 874 |
+
try:
|
| 875 |
+
base_img = open_image(input_image) if isinstance(input_image, str) else input_image
|
| 876 |
+
overlay = open_image(maze_output_path)
|
| 877 |
+
# ensure RGBA and resize overlay to match base image
|
| 878 |
+
if overlay.mode != 'RGBA':
|
| 879 |
+
overlay = overlay.convert('RGBA')
|
| 880 |
+
if overlay.size != base_img.size:
|
| 881 |
+
overlay = overlay.resize(base_img.size, Image.LANCZOS)
|
| 882 |
+
|
| 883 |
+
result = alpha_composite_with_control(base_img, overlay, opacity_percent)
|
| 884 |
+
|
| 885 |
+
# save to temp file and return path
|
| 886 |
+
with NamedTemporaryFile(delete=False, suffix=".png", prefix="maze_composite_") as outf:
|
| 887 |
+
result.convert('RGBA').save(outf.name, format='PNG')
|
| 888 |
+
constants.temp_files.append(outf.name)
|
| 889 |
+
return outf.name, status
|
| 890 |
+
except Exception as ex:
|
| 891 |
+
# fallback: return generated maze image if compositing fails
|
| 892 |
+
print(f"Maze compositing failed: {ex}")
|
| 893 |
+
return maze_output_path, status
|
| 894 |
################################################################## DEPTH ESTIMATION ###############################################################################
|
| 895 |
|
| 896 |
@spaces.GPU(progress=gr.Progress(track_tqdm=True))
|
|
|
|
| 1866 |
outputs=[input_image], scroll_to_output=True
|
| 1867 |
)
|
| 1868 |
|
| 1869 |
+
# Maze Maker handlers
|
| 1870 |
+
maze_shape.change(
|
| 1871 |
+
fn=update_maze_maker_shape_visibility,
|
| 1872 |
+
inputs=[maze_shape],
|
| 1873 |
+
outputs=[maze_width, maze_height, maze_radius]
|
| 1874 |
+
)
|
| 1875 |
+
|
| 1876 |
+
maze_apply_button.click(
|
| 1877 |
+
fn=generate_and_apply_maze_to_input,
|
| 1878 |
+
inputs=[maze_shape, maze_width, maze_height, maze_radius, maze_cell_size, maze_use_iterative, maze_show_solution, maze_show_doors, input_image, maze_opacity],
|
| 1879 |
+
outputs=[input_image, maze_status],
|
| 1880 |
+
scroll_to_output=True
|
| 1881 |
+
).then(
|
| 1882 |
+
fn=update_sketch_dimensions,
|
| 1883 |
+
inputs=[input_image, sketch_image],
|
| 1884 |
+
outputs=[sketch_image, sketch_image]
|
| 1885 |
+
)
|
| 1886 |
+
|
| 1887 |
##################### model #######################################
|
| 1888 |
model_textbox.change(
|
| 1889 |
fn=update_prompt_notes,
|
requirements.txt
CHANGED
|
@@ -106,3 +106,10 @@ https://huggingface.co/spaces/Surn/HexaGrid/resolve/main/wheels/diff_gaussian_ra
|
|
| 106 |
#https://github.com/PozzettiAndrea/cuda-wheels/releases/download/nvdiffrast-latest/nvdiffrast-0.4.0%2Bcu128torch2.9-cp312-cp312-win_amd64.whl
|
| 107 |
|
| 108 |
#https://github.com/PozzettiAndrea/cuda-wheels/releases/download/nvdiffrast-latest/nvdiffrast-0.4.0%2Bcu128torch2.10-cp312-cp312-win_amd64.whl
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
#https://github.com/PozzettiAndrea/cuda-wheels/releases/download/nvdiffrast-latest/nvdiffrast-0.4.0%2Bcu128torch2.9-cp312-cp312-win_amd64.whl
|
| 107 |
|
| 108 |
#https://github.com/PozzettiAndrea/cuda-wheels/releases/download/nvdiffrast-latest/nvdiffrast-0.4.0%2Bcu128torch2.10-cp312-cp312-win_amd64.whl
|
| 109 |
+
|
| 110 |
+
# kaolin update
|
| 111 |
+
|
| 112 |
+
tornado
|
| 113 |
+
flask
|
| 114 |
+
kaolin @ https://huggingface.co/spaces/Surn/HexaGrid/resolve/main/wheels/kaolin-0.18.0-cp312-cp312-linux_x86_64.whl ; sys_platform == "linux"
|
| 115 |
+
kaolin @ https://huggingface.co/spaces/Surn/HexaGrid/resolve/main/wheels/kaolin-0.18.0-cp312-cp312-win_amd64.whl ; sys_platform == "win32"
|
style_20250314.css
CHANGED
|
@@ -21,7 +21,7 @@
|
|
| 21 |
/*background-color: rgba(242, 218, 163, 0.62);*/
|
| 22 |
}
|
| 23 |
|
| 24 |
-
.dark .gradio-container.gradio-container-
|
| 25 |
/*background-color: rgba(41, 18, 5, 0.38) !important;*/
|
| 26 |
}
|
| 27 |
.toast-body.info {
|
|
|
|
| 21 |
/*background-color: rgba(242, 218, 163, 0.62);*/
|
| 22 |
}
|
| 23 |
|
| 24 |
+
.dark .gradio-container.gradio-container-6-10-0 .contain .intro .prose {
|
| 25 |
/*background-color: rgba(41, 18, 5, 0.38) !important;*/
|
| 26 |
}
|
| 27 |
.toast-body.info {
|
utils/constants.py
CHANGED
|
@@ -68,7 +68,6 @@ TMPDIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tmp')
|
|
| 68 |
os.makedirs(TMPDIR, exist_ok=True)
|
| 69 |
|
| 70 |
|
| 71 |
-
|
| 72 |
PROMPTS = {
|
| 73 |
"Mecha Wasteland Arena": "Regional overhead view, directly from above, centered on the map, orthographic Mecha battlefield map. post-industrial wasteland with crumbling structures, volcanic ridges, scrapyards, and ash plains. Features elevated overwatch positions for long-range combat and tight brawling areas for close-quarters engagements. Partial edge hexes are black. Colors: red, gray, muted orange, ash white, dark brown.",
|
| 74 |
"BorderBlack": "Planetary overhead view, directly from above, centered on the planet’s surface, orthographic hexagon-based alien world map with black borders. Features rivers, mountains, volcanoes, and polar snow regions. Colors: light blue, green, tan, brown. No reflections or shadows. Partial edge hexes are black.",
|
|
@@ -209,7 +208,8 @@ MODELS = [
|
|
| 209 |
"black-forest-labs/FLUX.1-schnell",
|
| 210 |
"black-forest-labs/FLUX.1-dev",
|
| 211 |
"ostris/Flex.1-alpha",
|
| 212 |
-
"black-forest-labs/FLUX.1-Fill-dev"
|
|
|
|
| 213 |
]
|
| 214 |
# Available LoRA weights
|
| 215 |
LORA_WEIGHTS = [
|
|
@@ -227,6 +227,8 @@ LORA_WEIGHTS = [
|
|
| 227 |
"gokaygokay/Flux-Seamless-Texture-LoRA",
|
| 228 |
"gokaygokay/Flux-Game-Assets-LoRA-v2",
|
| 229 |
"lrzjason/ObjectRemovalFluxFill",
|
|
|
|
|
|
|
| 230 |
]
|
| 231 |
|
| 232 |
# Map each LoRA weight to its corresponding model
|
|
@@ -246,6 +248,8 @@ LORA_TO_MODEL = {
|
|
| 246 |
"gokaygokay/Flux-Seamless-Texture-LoRA":"black-forest-labs/FLUX.1-dev",
|
| 247 |
"gokaygokay/Flux-Game-Assets-LoRA-v2":"black-forest-labs/FLUX.1-dev",
|
| 248 |
"lrzjason/ObjectRemovalFluxFill":"black-forest-labs/FLUX.1-Fill-dev",
|
|
|
|
|
|
|
| 249 |
}
|
| 250 |
condition_type = ["depth", "canny", "subject", "coloring", "deblurring", "fill", "redux"]
|
| 251 |
|
|
@@ -489,7 +493,7 @@ LORA_DETAILS = {
|
|
| 489 |
},
|
| 490 |
{
|
| 491 |
"thumbnail": "https://huggingface.co/Borcherding/FLUX.1-dev-LoRA-FractalLand-v0.1/resolve/main/images/example_e2zoqwftv.png",
|
| 492 |
-
"show_in_gallery":
|
| 493 |
}
|
| 494 |
],
|
| 495 |
"strangerzonehf/Flux-Cute-3D-Kawaii-LoRA": [
|
|
@@ -585,6 +589,22 @@ LORA_DETAILS = {
|
|
| 585 |
"show_in_gallery": False
|
| 586 |
}
|
| 587 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
}
|
| 589 |
|
| 590 |
def sort_loras(sortby):
|
|
@@ -686,4 +706,9 @@ audio_extensions = {".mp3", ".wav", ".ogg", ".flac", ".aac"}
|
|
| 686 |
audio_extensions_list = list(audio_extensions)
|
| 687 |
video_extensions = {".mp4"}
|
| 688 |
video_extensions_list = list(video_extensions)
|
| 689 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
os.makedirs(TMPDIR, exist_ok=True)
|
| 69 |
|
| 70 |
|
|
|
|
| 71 |
PROMPTS = {
|
| 72 |
"Mecha Wasteland Arena": "Regional overhead view, directly from above, centered on the map, orthographic Mecha battlefield map. post-industrial wasteland with crumbling structures, volcanic ridges, scrapyards, and ash plains. Features elevated overwatch positions for long-range combat and tight brawling areas for close-quarters engagements. Partial edge hexes are black. Colors: red, gray, muted orange, ash white, dark brown.",
|
| 73 |
"BorderBlack": "Planetary overhead view, directly from above, centered on the planet’s surface, orthographic hexagon-based alien world map with black borders. Features rivers, mountains, volcanoes, and polar snow regions. Colors: light blue, green, tan, brown. No reflections or shadows. Partial edge hexes are black.",
|
|
|
|
| 208 |
"black-forest-labs/FLUX.1-schnell",
|
| 209 |
"black-forest-labs/FLUX.1-dev",
|
| 210 |
"ostris/Flex.1-alpha",
|
| 211 |
+
"black-forest-labs/FLUX.1-Fill-dev",
|
| 212 |
+
"diffusers/FLUX.2-dev-bnb-4bit",
|
| 213 |
]
|
| 214 |
# Available LoRA weights
|
| 215 |
LORA_WEIGHTS = [
|
|
|
|
| 227 |
"gokaygokay/Flux-Seamless-Texture-LoRA",
|
| 228 |
"gokaygokay/Flux-Game-Assets-LoRA-v2",
|
| 229 |
"lrzjason/ObjectRemovalFluxFill",
|
| 230 |
+
"strangerzonehf/Flux-Super-Realism-LoRA",
|
| 231 |
+
"fal/FLUX.2-dev-Turbo",
|
| 232 |
]
|
| 233 |
|
| 234 |
# Map each LoRA weight to its corresponding model
|
|
|
|
| 248 |
"gokaygokay/Flux-Seamless-Texture-LoRA":"black-forest-labs/FLUX.1-dev",
|
| 249 |
"gokaygokay/Flux-Game-Assets-LoRA-v2":"black-forest-labs/FLUX.1-dev",
|
| 250 |
"lrzjason/ObjectRemovalFluxFill":"black-forest-labs/FLUX.1-Fill-dev",
|
| 251 |
+
"fal/FLUX.2-dev-Turbo": "black-forest-labs/FLUX.2-dev-Turbo",
|
| 252 |
+
"strangerzonehf/Flux-Super-Realism-LoRA": "black-forest-labs/FLUX.2-dev-Turbo"
|
| 253 |
}
|
| 254 |
condition_type = ["depth", "canny", "subject", "coloring", "deblurring", "fill", "redux"]
|
| 255 |
|
|
|
|
| 493 |
},
|
| 494 |
{
|
| 495 |
"thumbnail": "https://huggingface.co/Borcherding/FLUX.1-dev-LoRA-FractalLand-v0.1/resolve/main/images/example_e2zoqwftv.png",
|
| 496 |
+
"show_in_gallery": False
|
| 497 |
}
|
| 498 |
],
|
| 499 |
"strangerzonehf/Flux-Cute-3D-Kawaii-LoRA": [
|
|
|
|
| 589 |
"show_in_gallery": False
|
| 590 |
}
|
| 591 |
],
|
| 592 |
+
"fal/FLUX.2-dev-Turbo": [
|
| 593 |
+
{"weight_name": "flux.2-turbo-lora.safetensors"},
|
| 594 |
+
{"title": "FLUX.2 Turbo"},
|
| 595 |
+
{"trigger_words": "https://huggingface.co/spaces/Surn/HexaGrid/resolve/main/images/prerendered/flux2-turbo-lora.png"},
|
| 596 |
+
{"notes": "Turbo LoRA for 8-step fast generation."},
|
| 597 |
+
{"parameters": {"num_inference_steps": "8", "guidance_scale": "2.5"}},
|
| 598 |
+
{"thumbnail": "images/prerendered/flux2-turbo-lora.png", "show_in_gallery": True}
|
| 599 |
+
],
|
| 600 |
+
"strangerzonehf/Flux-Super-Realism-LoRA": [
|
| 601 |
+
{"trigger_words": "Super Realism, "},
|
| 602 |
+
{"weight_name": "super-realism.safetensors"},
|
| 603 |
+
{"title": "Super Realism"},
|
| 604 |
+
{"notes": "Excellent photorealism. Use 'realistic, photoreal' keywords too."},
|
| 605 |
+
{"parameters": {"num_inference_steps": "28", "guidance_scale": "3.5"}},
|
| 606 |
+
{"thumbnail": "https://huggingface.co/strangerzonehf/Flux-Super-Realism-LoRA/resolve/main/images/sz.png", "show_in_gallery": True}
|
| 607 |
+
]
|
| 608 |
}
|
| 609 |
|
| 610 |
def sort_loras(sortby):
|
|
|
|
| 706 |
audio_extensions_list = list(audio_extensions)
|
| 707 |
video_extensions = {".mp4"}
|
| 708 |
video_extensions_list = list(video_extensions)
|
| 709 |
+
# BattleWords storage module expects document types to be part of uploadable types.
|
| 710 |
+
# Keep this minimal for HexaGrid: JSON is used by the URL shortener registry.
|
| 711 |
+
doc_extensions = {".json"}
|
| 712 |
+
doc_extensions_list = list(doc_extensions)
|
| 713 |
+
|
| 714 |
+
upload_file_types = model_extensions_list + image_extensions_list + audio_extensions_list + video_extensions_list + doc_extensions_list
|
utils/file_utils.py
CHANGED
|
@@ -103,4 +103,23 @@ def get_unique_file_path(directory, filename, file_ext, counter=0):
|
|
| 103 |
return get_unique_file_path(directory, filename, file_ext, counter + 1)
|
| 104 |
|
| 105 |
# Example usage:
|
| 106 |
-
# new_file_path = get_unique_file_path(video_dir, title_file_name, video_new_ext)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
return get_unique_file_path(directory, filename, file_ext, counter + 1)
|
| 104 |
|
| 105 |
# Example usage:
|
| 106 |
+
# new_file_path = get_unique_file_path(video_dir, title_file_name, video_new_ext)
|
| 107 |
+
|
| 108 |
+
def generate_permalink_from_urls(model_url, hm_url, img_url, permalink_viewer_url="surn-3d-viewer.hf.space"):
|
| 109 |
+
"""
|
| 110 |
+
Constructs and returns a permalink URL with query string parameters for the viewer.
|
| 111 |
+
Each parameter is passed separately so that the image positions remain consistent.
|
| 112 |
+
|
| 113 |
+
Parameters:
|
| 114 |
+
model_url (str): Processed URL for the 3D model.
|
| 115 |
+
hm_url (str): Processed URL for the height map image.
|
| 116 |
+
img_url (str): Processed URL for the main image.
|
| 117 |
+
permalink_viewer_url (str): The base viewer URL.
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
str: The generated permalink URL.
|
| 121 |
+
"""
|
| 122 |
+
import urllib.parse
|
| 123 |
+
params = {"3d": model_url, "hm": hm_url, "image": img_url}
|
| 124 |
+
query_str = urllib.parse.urlencode(params)
|
| 125 |
+
return f"https://{permalink_viewer_url}/?{query_str}"
|
utils/image_utils.py
CHANGED
|
@@ -6,7 +6,7 @@ import base64
|
|
| 6 |
import numpy as np
|
| 7 |
import rembg
|
| 8 |
#from decimal import ROUND_CEILING
|
| 9 |
-
from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter, ImageDraw, ImageOps, ImageMath
|
| 10 |
from typing import List, Union, is_typeddict
|
| 11 |
#import numpy as np
|
| 12 |
#import math
|
|
@@ -1444,4 +1444,98 @@ def combine_depth_map_with_Image(image:Image, depth_img:Image, width:int, height
|
|
| 1444 |
resized_depth_image = resize_image_with_aspect_ratio(depth_img, width, height)
|
| 1445 |
depth_no_background = rembg.remove(resized_depth_image, session = rembg.new_session(rembg_session_name))
|
| 1446 |
combined_depth_img = multiply_and_blend_images(image, depth_no_background, (alpha / 100))
|
| 1447 |
-
return combined_depth_img
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import numpy as np
|
| 7 |
import rembg
|
| 8 |
#from decimal import ROUND_CEILING
|
| 9 |
+
from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter, ImageDraw, ImageOps, ImageMath, ImageFont
|
| 10 |
from typing import List, Union, is_typeddict
|
| 11 |
#import numpy as np
|
| 12 |
#import math
|
|
|
|
| 1444 |
resized_depth_image = resize_image_with_aspect_ratio(depth_img, width, height)
|
| 1445 |
depth_no_background = rembg.remove(resized_depth_image, session = rembg.new_session(rembg_session_name))
|
| 1446 |
combined_depth_img = multiply_and_blend_images(image, depth_no_background, (alpha / 100))
|
| 1447 |
+
return combined_depth_img
|
| 1448 |
+
|
| 1449 |
+
def add_watermark(
|
| 1450 |
+
original_img: "Image.Image",
|
| 1451 |
+
watermark_img: "Image.Image",
|
| 1452 |
+
overlay_text: str = "",
|
| 1453 |
+
watermark_size: tuple[int, int] = (100, 100),
|
| 1454 |
+
watermark_position: tuple[int, int] = None,
|
| 1455 |
+
text_position: tuple[str, str] = ("bottom", "right"),
|
| 1456 |
+
text_color: tuple[int, int, int, int] = (107, 78, 156, 255),
|
| 1457 |
+
text_font_path: str = "fonts/Roboto-Regular.ttf",
|
| 1458 |
+
text_font_size: int = 20,
|
| 1459 |
+
blend_alpha: float = 0.4,
|
| 1460 |
+
dpi: tuple[int, int] = None
|
| 1461 |
+
) -> "Image.Image":
|
| 1462 |
+
"""
|
| 1463 |
+
Adds a watermark overlay onto the original image by pasting the watermark image
|
| 1464 |
+
(resized to watermark_size) and overlaying the provided text.
|
| 1465 |
+
|
| 1466 |
+
Parameters:
|
| 1467 |
+
original_img (Image.Image): The input image to watermark. Expected in RGBA mode.
|
| 1468 |
+
watermark_img (Image.Image): The watermark image to overlay. Expected in RGBA mode.
|
| 1469 |
+
overlay_text (str): Optional text to overlay on the image.
|
| 1470 |
+
watermark_size (tuple[int, int]): The desired size for the watermark image.
|
| 1471 |
+
watermark_position (tuple[int, int]): The position (x, y) where the watermark image will be placed.
|
| 1472 |
+
text_position (tuple[str, str]): (vertical, horizontal) position: ("top"|"middle"|"center"|"bottom", "left"|"center"|"middle"|"right")
|
| 1473 |
+
text_color (tuple[int, int, int, int]): The RGBA color for the overlay text.
|
| 1474 |
+
text_font_path (str): Path to the TTF font file used for the overlay text.
|
| 1475 |
+
text_font_size (int): Font size for the overlay text.
|
| 1476 |
+
blend_alpha (float): The alpha value (0.0 to 1.0) for blending the watermark layer with the original image.
|
| 1477 |
+
dpi (tuple[int, int]): DPI to use for output. If None, uses original image DPI if available.
|
| 1478 |
+
|
| 1479 |
+
Returns:
|
| 1480 |
+
Image.Image: The watermarked image.
|
| 1481 |
+
"""
|
| 1482 |
+
# Ensure original is RGBA
|
| 1483 |
+
original = original_img.convert("RGBA")
|
| 1484 |
+
width, height = original.size
|
| 1485 |
+
|
| 1486 |
+
# Set default positions if not provided
|
| 1487 |
+
margin = 10
|
| 1488 |
+
if watermark_position is None:
|
| 1489 |
+
watermark_position = (width - watermark_size[0] - margin, height - watermark_size[1] - margin)
|
| 1490 |
+
|
| 1491 |
+
# Resize watermark image
|
| 1492 |
+
watermark_resized = watermark_img.resize(watermark_size, Image.Resampling.LANCZOS)
|
| 1493 |
+
|
| 1494 |
+
# Create an empty transparent layer for watermark
|
| 1495 |
+
watermark_layer = Image.new("RGBA", original.size, (0, 0, 0, 0))
|
| 1496 |
+
# Paste the watermark image onto the watermark layer at the specified position
|
| 1497 |
+
watermark_layer.paste(watermark_resized, watermark_position, watermark_resized)
|
| 1498 |
+
|
| 1499 |
+
# Draw overlay text if provided
|
| 1500 |
+
if overlay_text:
|
| 1501 |
+
draw = ImageDraw.Draw(watermark_layer)
|
| 1502 |
+
try:
|
| 1503 |
+
font = ImageFont.truetype(text_font_path, text_font_size)
|
| 1504 |
+
except Exception as e:
|
| 1505 |
+
print(f"Failed to load font {text_font_path}: {e}")
|
| 1506 |
+
font = ImageFont.load_default()
|
| 1507 |
+
# Calculate text size
|
| 1508 |
+
text_bbox = draw.textbbox((0, 0), overlay_text, font=font)
|
| 1509 |
+
text_width = text_bbox[2] - text_bbox[0]
|
| 1510 |
+
text_height = text_bbox[3] - text_bbox[1]
|
| 1511 |
+
# Determine text position based on (vertical, horizontal) keywords
|
| 1512 |
+
vertical, horizontal = text_position
|
| 1513 |
+
# Place text over the watermark image
|
| 1514 |
+
wm_x, wm_y = watermark_position
|
| 1515 |
+
wm_w, wm_h = watermark_size
|
| 1516 |
+
# Default to center of watermark
|
| 1517 |
+
x = wm_x + (wm_w - text_width) // 2
|
| 1518 |
+
y = wm_y + (wm_h - text_height) // 2
|
| 1519 |
+
if vertical == "top":
|
| 1520 |
+
y = wm_y + margin
|
| 1521 |
+
elif vertical in ("middle", "center"):
|
| 1522 |
+
y = wm_y + (wm_h - text_height) // 2
|
| 1523 |
+
elif vertical == "bottom":
|
| 1524 |
+
y = wm_y + wm_h - text_height - margin
|
| 1525 |
+
if horizontal == "left":
|
| 1526 |
+
x = wm_x + margin
|
| 1527 |
+
elif horizontal in ("middle", "center"):
|
| 1528 |
+
x = wm_x + (wm_w - text_width) // 2
|
| 1529 |
+
elif horizontal == "right":
|
| 1530 |
+
x = wm_x + wm_w - text_width - margin
|
| 1531 |
+
draw.text((x, y), overlay_text, fill=text_color, font=font)
|
| 1532 |
+
|
| 1533 |
+
# Blend the watermark layer over the original image using the specified alpha
|
| 1534 |
+
watermarked = Image.blend(original, watermark_layer, alpha=blend_alpha)
|
| 1535 |
+
# Preserve DPI if possible
|
| 1536 |
+
if dpi is None:
|
| 1537 |
+
dpi = original.info.get("dpi", (72, 72))
|
| 1538 |
+
# Ensure output image has original size
|
| 1539 |
+
watermarked = watermarked.resize((width, height), Image.Resampling.LANCZOS)
|
| 1540 |
+
watermarked.info["dpi"] = dpi
|
| 1541 |
+
return watermarked
|
utils/mazlib/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# Maze Maker
|
| 3 |
+
|
| 4 |
+
Generate, solve, and render mazes in Python.
|
| 5 |
+
|
| 6 |
+
## Features
|
| 7 |
+
|
| 8 |
+
- Rectangular mazes (recursive or iterative DFS)
|
| 9 |
+
- Triangular mazes
|
| 10 |
+
- Hexagonal mazes (axial coordinates)
|
| 11 |
+
- Shortest-path solving with BFS
|
| 12 |
+
- Image rendering with Pillow
|
| 13 |
+
- CLI and Python API support
|
| 14 |
+
- Deterministic generation via --seed / seed parameter (non‑zero)
|
| 15 |
+
|
| 16 |
+
## Installation
|
| 17 |
+
|
| 18 |
+
Requirements:
|
| 19 |
+
|
| 20 |
+
- Python 3.8+
|
| 21 |
+
- Pillow>=9.0
|
| 22 |
+
|
| 23 |
+
Install from source:
|
| 24 |
+
|
| 25 |
+
```sh
|
| 26 |
+
git clone https://github.com/Oncorporation/maze_maker.git
|
| 27 |
+
cd maze_maker
|
| 28 |
+
python -m pip install --upgrade pip
|
| 29 |
+
pip install -r requirements.txt
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
Optional editable install:
|
| 33 |
+
|
| 34 |
+
```sh
|
| 35 |
+
pip install -e .
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## Usage
|
| 39 |
+
|
| 40 |
+
CLI:
|
| 41 |
+
|
| 42 |
+
```sh
|
| 43 |
+
python -m mazlib.cli --help
|
| 44 |
+
python -m mazlib.cli -s rect -z 31x21 -c 20 -O out --show-solution
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
Python API:
|
| 48 |
+
|
| 49 |
+
```python
|
| 50 |
+
from mazlib.cli import run_maze
|
| 51 |
+
|
| 52 |
+
# non-deterministic (seed=0 uses system randomness)
|
| 53 |
+
maze, path = run_maze(shape="rect", size=(31, 21), cell=20, draw=True)
|
| 54 |
+
|
| 55 |
+
# deterministic: pass a non-zero seed
|
| 56 |
+
maze, path = run_maze(shape="rect", size=(31, 21), cell=20, draw=True, seed=42)
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### `run_maze` parameters
|
| 60 |
+
|
| 61 |
+
`run_maze(shape='rect', size=(30,20), cell=20, use_iterative=False, draw=True, output=None, show_solution=True, show_doors=False, seed=0)`
|
| 62 |
+
|
| 63 |
+
- `shape` (`str`): maze shape.
|
| 64 |
+
- Canonical values: `rect`, `tri`, `hex`
|
| 65 |
+
- Also accepted by `run_maze`: `rectangle`, `grid`, `triangle`, `triangular`, `hexagon`, `hexagonal`
|
| 66 |
+
- `size` (`tuple|int`):
|
| 67 |
+
- Rect/Tri: `(width, height)` or single `int` (square)
|
| 68 |
+
- Hex: `int` radius
|
| 69 |
+
- `cell` (`int`): cell size in pixels (`cell_size` for hex drawing internally).
|
| 70 |
+
- `use_iterative` (`bool`): use iterative generator variant.
|
| 71 |
+
- `draw` (`bool`): when `True`, render maze image.
|
| 72 |
+
- `output` (`str|None`): output image path. If `None`, no file is saved and the image is shown (when drawing).
|
| 73 |
+
- `show_solution` (`bool`): when `True`, include the solved path overlay in rendering.
|
| 74 |
+
- `show_doors` (`bool`): draw light outline guides/cell boundaries.
|
| 75 |
+
- `seed` (`int`): optional random seed. Non-zero seeds produce deterministic mazes; `0` (default) uses system randomness.
|
| 76 |
+
|
| 77 |
+
Return value:
|
| 78 |
+
|
| 79 |
+
- `(maze, path)`
|
| 80 |
+
- `maze`: list of lists (rect) or dict (tri/hex)
|
| 81 |
+
- `path`: list of coordinates for the solution path
|
| 82 |
+
|
| 83 |
+
Notes on determinism and seeding
|
| 84 |
+
|
| 85 |
+
- Passing a non-zero `seed` to `run_maze` or the individual generators (`generate_maze`,
|
| 86 |
+
`generate_maze_iterative`, `generate_hex_maze_iterative`, `generate_tri_maze`, etc.)
|
| 87 |
+
uses a private Random instance seeded with that value. That makes generation deterministic
|
| 88 |
+
and repeatable across runs when the same seed is used.
|
| 89 |
+
|
| 90 |
+
### Valid shapes in CLI vs API
|
| 91 |
+
|
| 92 |
+
- CLI `--shape` accepted values: `rect`, `tri`, `hex`
|
| 93 |
+
- Python `run_maze(shape=...)` accepts the aliases listed above
|
| 94 |
+
|
| 95 |
+
### Image file naming with `mazlib.cli`
|
| 96 |
+
|
| 97 |
+
`run_maze` supports two output modes from the CLI:
|
| 98 |
+
|
| 99 |
+
- `-o/--output`: use an exact filename you provide.
|
| 100 |
+
- `-O/--out-dir`: auto-generate a filename in the given directory.
|
| 101 |
+
|
| 102 |
+
Auto-generated filename patterns (when using `-O`):
|
| 103 |
+
|
| 104 |
+
- Rectangular: `maze_rect_<width>x<height>_<YYYYMMDD_HHMMSS>.png`
|
| 105 |
+
- Triangular: `maze_tri_<width>x<height>_<YYYYMMDD_HHMMSS>.png`
|
| 106 |
+
- Hexagonal: `maze_hex_r<radius>_<YYYYMMDD_HHMMSS>.png`
|
| 107 |
+
|
| 108 |
+
When a non-zero `--seed` is provided to the CLI, the seed is appended to the
|
| 109 |
+
auto-generated filename as `_s<seed>` (for example `maze_rect_31x21_20230101_123000_s42.png`) so
|
| 110 |
+
deterministic outputs are easy to correlate with their seed.
|
| 111 |
+
|
| 112 |
+
Examples:
|
| 113 |
+
|
| 114 |
+
```sh
|
| 115 |
+
# Explicit filename
|
| 116 |
+
python -m mazlib.cli -s rect -z 31x21 -o my_custom_maze.png
|
| 117 |
+
|
| 118 |
+
# Auto-generated filename in ./out with deterministic seed appended
|
| 119 |
+
python -m mazlib.cli -s rect -z 31x21 -O out --seed 42
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
### Drawing customization and colors
|
| 123 |
+
|
| 124 |
+
The CLI exposes options to control drawing appearance and colors. Use `--show-doors` to draw thin outlines for cell/hex/triangle doors. You can override individual colors with these flags:
|
| 125 |
+
|
| 126 |
+
- `--color-wall` (default: `black`)
|
| 127 |
+
- `--color-floor` (default: `white`)
|
| 128 |
+
- `--color-start` (default: `green`)
|
| 129 |
+
- `--color-door` (default: `#B0B0B0`)
|
| 130 |
+
- `--color-path` (default: `red`)
|
| 131 |
+
|
| 132 |
+
Example (custom colors):
|
| 133 |
+
|
| 134 |
+
```sh
|
| 135 |
+
python -m mazlib.cli -s rect -z 31x21 -O out --show-doors --color-wall navy --color-start lime --color-path orange
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
Programmatic API users can pass a color_dict to `run_maze` or to the draw functions:
|
| 139 |
+
|
| 140 |
+
```python
|
| 141 |
+
colors = {'wall':'navy', 'floor':'#f8f8f8', 'start':'lime', 'door':'#cccccc', 'path':'orange'}
|
| 142 |
+
maze, path = run_maze(shape='rect', size=(31,21), draw=True, seed=42, color_dict=colors)
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
### Drawing notes
|
| 146 |
+
|
| 147 |
+
- The triangular maze drawing now reliably marks the starting triangle in green
|
| 148 |
+
(same visual cue used by rectangular and hex drawing functions).
|
| 149 |
+
|
| 150 |
+
## Git Submodule (quick reference)
|
| 151 |
+
|
| 152 |
+
Add:
|
| 153 |
+
|
| 154 |
+
```sh
|
| 155 |
+
git submodule add https://github.com/Oncorporation/maze_maker.git submodules/maze_maker
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
Clone with submodules:
|
| 159 |
+
|
| 160 |
+
```sh
|
| 161 |
+
git clone --recurse-submodules <parent-repo-url>
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
Update after clone:
|
| 165 |
+
|
| 166 |
+
```sh
|
| 167 |
+
git submodule update --init --recursive
|
| 168 |
+
```
|
| 169 |
+
|
utils/mazlib/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Public API for mazlib package.
|
| 2 |
+
|
| 3 |
+
Re-exports generators, solvers and drawing helpers for rectangular and hex
|
| 4 |
+
mazes. This package is a lightweight refactor of the original top-level
|
| 5 |
+
maz.py to make the code easier to test and reuse.
|
| 6 |
+
"""
|
| 7 |
+
from .grid import (
|
| 8 |
+
generate_maze,
|
| 9 |
+
generate_maze_iterative,
|
| 10 |
+
solve_maze,
|
| 11 |
+
)
|
| 12 |
+
from .hex import (
|
| 13 |
+
generate_hex_maze,
|
| 14 |
+
generate_hex_maze_iterative,
|
| 15 |
+
solve_hex_maze,
|
| 16 |
+
)
|
| 17 |
+
from .draw import (
|
| 18 |
+
draw_maze,
|
| 19 |
+
draw_hex_maze,
|
| 20 |
+
draw_tri_maze,
|
| 21 |
+
)
|
| 22 |
+
from .tri import (
|
| 23 |
+
generate_tri_maze,
|
| 24 |
+
generate_tri_maze_iterative,
|
| 25 |
+
solve_tri_maze,
|
| 26 |
+
)
|
| 27 |
+
from .cli import run_maze
|
| 28 |
+
|
| 29 |
+
__all__ = [
|
| 30 |
+
'generate_maze', 'generate_maze_iterative', 'solve_maze',
|
| 31 |
+
'generate_hex_maze', 'generate_hex_maze_iterative', 'solve_hex_maze',
|
| 32 |
+
'draw_maze', 'draw_hex_maze', 'run_maze',
|
| 33 |
+
'generate_tri_maze', 'generate_tri_maze_iterative', 'solve_tri_maze', 'draw_tri_maze',
|
| 34 |
+
]
|
utils/mazlib/cli.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .grid import generate_maze, generate_maze_iterative, solve_maze
|
| 2 |
+
from .hex import generate_hex_maze, generate_hex_maze_iterative, solve_hex_maze
|
| 3 |
+
from .draw import draw_maze, draw_hex_maze, draw_tri_maze
|
| 4 |
+
from .tri import generate_tri_maze, generate_tri_maze_iterative, solve_tri_maze
|
| 5 |
+
import argparse
|
| 6 |
+
import sys
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def run_maze(shape='rect', size=(30,20), cell=20, use_iterative=False, draw=True, output=None, show_solution=True, show_doors=False, seed=0, color_dict={'wall':'black', 'floor':'white', 'start':'green', 'door':'#B0B0B0', 'path':'red'}):
|
| 14 |
+
"""
|
| 15 |
+
Control function to generate, solve, and optionally draw a maze.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
shape (str): 'rect' for rectangular grid mazes or 'hex' for hexagonal mazes.
|
| 19 |
+
size (tuple|int): For 'rect', a (width, height) tuple. For 'hex', an integer radius.
|
| 20 |
+
cell (int): Cell size in pixels for drawing (rect) or approximate hex size (hex).
|
| 21 |
+
use_iterative (bool): If True and shape is 'rect', use the iterative DFS generator.
|
| 22 |
+
draw (bool): If True, render the maze and path with PIL; otherwise only return data.
|
| 23 |
+
output (str|None): If provided, save the rendered image to this path; if None, show the image.
|
| 24 |
+
show_solution (bool): If True, overlay the solution path on the drawn maze.
|
| 25 |
+
show_doors (bool): If True, draw thin grey outlines around each cell to show doors.
|
| 26 |
+
seed (int): Random seed for maze generation (0 for random).
|
| 27 |
+
color_dict (dict): Optional dict to specify colors for 'wall', 'floor', 'start', 'door', and 'path' in drawing.
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
tuple: (maze, path) where maze is either a 2D list (rect) or dict (hex), and
|
| 31 |
+
path is a list of coordinates in the corresponding coordinate system.
|
| 32 |
+
"""
|
| 33 |
+
logging.debug('run_maze called: shape=%s size=%s cell=%s iterative=%s draw=%s output=%s show_solution=%s',
|
| 34 |
+
shape, size, cell, use_iterative, draw, output, show_solution)
|
| 35 |
+
shape_key = (shape or 'rect').lower()
|
| 36 |
+
if shape_key in ('rect', 'rectangle', 'grid'):
|
| 37 |
+
# Normalize size into (w, h)
|
| 38 |
+
if isinstance(size, int):
|
| 39 |
+
w, h = size, size
|
| 40 |
+
else:
|
| 41 |
+
try:
|
| 42 |
+
w, h = int(size[0]), int(size[1])
|
| 43 |
+
except Exception:
|
| 44 |
+
raise ValueError("For rect shape, size must be an int or a (w,h) tuple")
|
| 45 |
+
if use_iterative:
|
| 46 |
+
maze = generate_maze_iterative(w, h, seed=seed)
|
| 47 |
+
else:
|
| 48 |
+
maze = generate_maze(w, h, seed=seed)
|
| 49 |
+
path = solve_maze(maze)
|
| 50 |
+
if draw:
|
| 51 |
+
draw_maze(maze, path if show_solution else [], cell=cell, save_path=output, show_doors=show_doors, color_dict=color_dict)
|
| 52 |
+
return maze, path
|
| 53 |
+
|
| 54 |
+
if shape_key in ('tri', 'triangle', 'triangular'):
|
| 55 |
+
# triangular grids are currently implemented as rectangular compatibility wrappers
|
| 56 |
+
if isinstance(size, int):
|
| 57 |
+
w, h = size, size
|
| 58 |
+
else:
|
| 59 |
+
try:
|
| 60 |
+
w, h = int(size[0]), int(size[1])
|
| 61 |
+
except Exception:
|
| 62 |
+
raise ValueError("For tri shape, size must be an int or a (w,h) tuple")
|
| 63 |
+
if use_iterative:
|
| 64 |
+
maze = generate_tri_maze_iterative(w, h, seed=seed)
|
| 65 |
+
else:
|
| 66 |
+
maze = generate_tri_maze(w, h, seed=seed)
|
| 67 |
+
path = solve_tri_maze(maze)
|
| 68 |
+
if draw:
|
| 69 |
+
draw_tri_maze(maze, path if show_solution else [], cell=cell, save_path=output, show_doors=show_doors, color_dict=color_dict)
|
| 70 |
+
return maze, path
|
| 71 |
+
|
| 72 |
+
if shape_key in ('hex', 'hexagon', 'hexagonal'):
|
| 73 |
+
try:
|
| 74 |
+
radius = int(size)
|
| 75 |
+
except Exception:
|
| 76 |
+
raise ValueError("For hex shape, size must be an integer radius")
|
| 77 |
+
# allow choosing iterative implementation explicitly
|
| 78 |
+
if use_iterative:
|
| 79 |
+
maze = generate_hex_maze_iterative(radius, seed=seed)
|
| 80 |
+
else:
|
| 81 |
+
maze = generate_hex_maze(radius, seed=seed)
|
| 82 |
+
path = solve_hex_maze(maze)
|
| 83 |
+
if draw:
|
| 84 |
+
draw_hex_maze(maze, path if show_solution else [], cell_size=cell, save_path=output, show_doors=show_doors, color_dict=color_dict)
|
| 85 |
+
return maze, path
|
| 86 |
+
|
| 87 |
+
raise ValueError(f"Unknown shape: {shape}")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _parse_size_arg(shape, size_str):
|
| 91 |
+
"""Parse the --size CLI argument into the appropriate value for run_maze.
|
| 92 |
+
|
| 93 |
+
For rectangular mazes accept either an integer (square) or WIDTHxHEIGHT.
|
| 94 |
+
For hex mazes accept a single integer radius.
|
| 95 |
+
"""
|
| 96 |
+
if shape in ('rect', 'rectangle', 'grid', 'tri', 'triangle', 'triangular'):
|
| 97 |
+
if isinstance(size_str, (list, tuple)):
|
| 98 |
+
return int(size_str[0]), int(size_str[1])
|
| 99 |
+
s = str(size_str)
|
| 100 |
+
if 'x' in s.lower():
|
| 101 |
+
parts = s.lower().split('x')
|
| 102 |
+
if len(parts) != 2:
|
| 103 |
+
raise ValueError("Invalid rect size format, expected WIDTHxHEIGHT")
|
| 104 |
+
return int(parts[0]), int(parts[1])
|
| 105 |
+
return int(s)
|
| 106 |
+
# hex
|
| 107 |
+
return int(size_str)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def main(argv=None):
|
| 111 |
+
parser = argparse.ArgumentParser(prog='run_maze', description='Generate/solve/draw mazes')
|
| 112 |
+
parser.add_argument('-s', '--shape', choices=['rect', 'hex', 'tri'], default='rect', help='Maze shape')
|
| 113 |
+
parser.add_argument('-z', '--size', default='30x20', help='Size: for rect WIDTHxHEIGHT or N; for hex radius')
|
| 114 |
+
parser.add_argument('-c', '--cell', type=int, default=20, help='Cell size in pixels')
|
| 115 |
+
parser.add_argument('-i', '--iterative', action='store_true', help='Use iterative generator')
|
| 116 |
+
parser.add_argument('--no-draw', dest='draw', action='store_false', help="Don't render the maze")
|
| 117 |
+
group = parser.add_mutually_exclusive_group()
|
| 118 |
+
group.add_argument('-o', '--output', default=None, help='Path to save rendered image (if omitted, image will be shown)')
|
| 119 |
+
group.add_argument('-O', '--out-dir', default=None, help='Directory to save rendered image(s); filename auto-generated')
|
| 120 |
+
parser.add_argument('-v', '--verbose', action='count', default=0, help='Increase verbosity (use -v or -vv)')
|
| 121 |
+
parser.add_argument('--show-solution', action='store_true', default=False,
|
| 122 |
+
help='Print path length and endpoints after solving')
|
| 123 |
+
parser.add_argument('--seed', type=int, default=0, help='Random seed (0 for random)')
|
| 124 |
+
parser.add_argument('--show-doors', action='store_true', default=False, help='Draw thin grey outlines for doors')
|
| 125 |
+
parser.add_argument('--color-wall', default='black', help='Color for walls in drawing')
|
| 126 |
+
parser.add_argument('--color-floor', default='white', help='Color for floors in drawing')
|
| 127 |
+
parser.add_argument('--color-start', default='green', help='Color for start cell in drawing')
|
| 128 |
+
parser.add_argument('--color-door', default='#B0B0B0', help='Color for doors in drawing')
|
| 129 |
+
parser.add_argument('--color-path', default='red', help='Color for solution path in drawing')
|
| 130 |
+
args = parser.parse_args(argv)
|
| 131 |
+
|
| 132 |
+
# configure logging according to verbosity
|
| 133 |
+
if args.verbose >= 2:
|
| 134 |
+
level = logging.DEBUG
|
| 135 |
+
elif args.verbose == 1:
|
| 136 |
+
level = logging.INFO
|
| 137 |
+
else:
|
| 138 |
+
level = logging.WARNING
|
| 139 |
+
logging.basicConfig(level=level, format='%(levelname)s: %(message)s')
|
| 140 |
+
|
| 141 |
+
try:
|
| 142 |
+
size = _parse_size_arg(args.shape, args.size)
|
| 143 |
+
except Exception as e:
|
| 144 |
+
parser.error(str(e))
|
| 145 |
+
|
| 146 |
+
# Ensure rectangular mazes have odd dimensions to allow a valid path
|
| 147 |
+
if args.shape in ('rect', 'rectangle', 'grid', 'tri', 'triangle', 'triangular'):
|
| 148 |
+
if isinstance(size, (list, tuple)):
|
| 149 |
+
w, h = int(size[0]), int(size[1])
|
| 150 |
+
changed = False
|
| 151 |
+
if w % 2 == 0:
|
| 152 |
+
w += 1
|
| 153 |
+
changed = True
|
| 154 |
+
if h % 2 == 0:
|
| 155 |
+
h += 1
|
| 156 |
+
changed = True
|
| 157 |
+
if changed:
|
| 158 |
+
logging.info("Adjusted rectangular size to odd dimensions: %dx%d", w, h)
|
| 159 |
+
size = (w, h)
|
| 160 |
+
else:
|
| 161 |
+
s = int(size)
|
| 162 |
+
if s % 2 == 0:
|
| 163 |
+
s += 1
|
| 164 |
+
logging.info("Adjusted rectangular size to odd dimension: %d", s)
|
| 165 |
+
size = s
|
| 166 |
+
|
| 167 |
+
# determine output path: explicit output > out_dir auto-generated > None
|
| 168 |
+
output_path = None
|
| 169 |
+
if args.output:
|
| 170 |
+
output_path = args.output
|
| 171 |
+
elif args.out_dir:
|
| 172 |
+
outdir = Path(args.out_dir)
|
| 173 |
+
try:
|
| 174 |
+
outdir.mkdir(parents=True, exist_ok=True)
|
| 175 |
+
except Exception as e:
|
| 176 |
+
parser.error(f"Failed to create out-dir '{args.out_dir}': {e}")
|
| 177 |
+
ts = time.strftime('%Y%m%d_%H%M%S')
|
| 178 |
+
shape_key = args.shape.lower()
|
| 179 |
+
if shape_key in ('rect', 'rectangle', 'grid', 'tri', 'triangle', 'triangular'):
|
| 180 |
+
if isinstance(size, tuple):
|
| 181 |
+
w, h = size
|
| 182 |
+
if shape_key in ('tri', 'triangle', 'triangular'):
|
| 183 |
+
fname = f"maze_tri_{w}x{h}_{ts}.png"
|
| 184 |
+
else:
|
| 185 |
+
fname = f"maze_rect_{w}x{h}_{ts}.png"
|
| 186 |
+
else:
|
| 187 |
+
s = int(size)
|
| 188 |
+
if shape_key in ('tri', 'triangle', 'triangular'):
|
| 189 |
+
fname = f"maze_tri_{s}x{s}_{ts}.png"
|
| 190 |
+
else:
|
| 191 |
+
fname = f"maze_rect_{s}x{s}_{ts}.png"
|
| 192 |
+
else:
|
| 193 |
+
# hex
|
| 194 |
+
radius = int(size)
|
| 195 |
+
fname = f"maze_hex_r{radius}_{ts}.png"
|
| 196 |
+
# append seed to filename when a non-zero seed is provided
|
| 197 |
+
if args.seed:
|
| 198 |
+
name, ext = os.path.splitext(fname)
|
| 199 |
+
fname = f"{name}_s{args.seed}{ext}"
|
| 200 |
+
output_path = str(outdir / fname)
|
| 201 |
+
logging.info("Auto-generated output path: %s", output_path)
|
| 202 |
+
|
| 203 |
+
color_dict = {
|
| 204 |
+
'wall': args.color_wall,
|
| 205 |
+
'floor': args.color_floor,
|
| 206 |
+
'start': args.color_start,
|
| 207 |
+
'door': args.color_door,
|
| 208 |
+
'path': args.color_path
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
maze, path = run_maze(shape=args.shape, size=size, cell=args.cell, use_iterative=args.iterative, draw=args.draw, output=output_path, show_solution=args.show_solution, seed=args.seed, color_dict=color_dict)
|
| 212 |
+
|
| 213 |
+
if args.show_solution:
|
| 214 |
+
if not path:
|
| 215 |
+
print("No solution path found.")
|
| 216 |
+
else:
|
| 217 |
+
try:
|
| 218 |
+
start = path[0]
|
| 219 |
+
end = path[-1]
|
| 220 |
+
print(f"Path length: {len(path)}")
|
| 221 |
+
print(f"Start: {start}")
|
| 222 |
+
print(f"End: {end}")
|
| 223 |
+
except Exception:
|
| 224 |
+
# Fallback: print minimal info
|
| 225 |
+
print(f"Path length: {len(path)}; start/end unavailable")
|
| 226 |
+
|
| 227 |
+
return maze, path
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
if __name__ == '__main__':
|
| 231 |
+
main()
|
utils/mazlib/draw.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
import logging
|
| 3 |
+
from PIL import Image, ImageDraw
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def draw_maze(maze, path, cell=20, save_path=None, show_doors=False, color_dict={'wall':'black', 'floor':'white', 'start':'green', 'door':'#B0B0B0', 'path':'red'}):
|
| 7 |
+
"""
|
| 8 |
+
Draw the maze and an optional path using PIL.
|
| 9 |
+
|
| 10 |
+
Args:
|
| 11 |
+
maze (list[list[int]]): 2D grid with 1 for walls and 0 for open cells.
|
| 12 |
+
path (list[tuple[int,int]]): sequence of (x, y) coordinates forming a path through the maze.
|
| 13 |
+
cell (int): pixel size of one maze cell when rendering. Default is 20.
|
| 14 |
+
save_path (str|None): if given, save the rendered image to this file path; if None, show the image instead.
|
| 15 |
+
show_doors (bool): if True, draw thin grey outlines around each cell to indicate doors.
|
| 16 |
+
color_dict (dict): optional dict to specify colors for 'wall', 'floor', 'start', 'door', and 'path'.
|
| 17 |
+
"""
|
| 18 |
+
h,w = len(maze),len(maze[0])
|
| 19 |
+
img = Image.new('RGB', (w*cell,h*cell), color_dict.get('floor', 'white'))
|
| 20 |
+
draw = ImageDraw.Draw(img)
|
| 21 |
+
|
| 22 |
+
# fill start cell (0,0) with green floor
|
| 23 |
+
draw.rectangle([0, 0, cell, cell], fill=color_dict.get('start', 'green'))
|
| 24 |
+
# Optional thin grey outlines for each cell (like hex show_doors)
|
| 25 |
+
if show_doors:
|
| 26 |
+
for y in range(h):
|
| 27 |
+
for x in range(w):
|
| 28 |
+
draw.rectangle(
|
| 29 |
+
[x * cell, y * cell, (x + 1) * cell, (y + 1) * cell],
|
| 30 |
+
outline=color_dict.get('door', '#B0B0B0'),
|
| 31 |
+
width=1
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Draw solid walls
|
| 35 |
+
for y in range(h):
|
| 36 |
+
for x in range(w):
|
| 37 |
+
if maze[y][x]:
|
| 38 |
+
draw.rectangle([x*cell,y*cell,(x+1)*cell,(y+1)*cell], fill=color_dict.get('wall', 'black'))
|
| 39 |
+
|
| 40 |
+
# Draw 2px exterior border, leaving an opening at the exit cell (w-1, h-1)
|
| 41 |
+
img_w, img_h = w * cell, h * cell
|
| 42 |
+
border_width = 4
|
| 43 |
+
draw.rectangle([0, 0, img_w - 1, img_h - 1], outline=color_dict.get('wall', 'black'), width=border_width)
|
| 44 |
+
ex0, ey0 = (w - 1) * cell, (h - 1) * cell
|
| 45 |
+
draw.line([(img_w - 1, ey0), (img_w - 1, ey0 + cell)], fill=color_dict.get('door', '#B0B0B0'), width=border_width + 2)
|
| 46 |
+
draw.line([(ex0, img_h - 1), (ex0 + cell, img_h - 1)], fill=color_dict.get('door', '#B0B0B0'), width=border_width + 2)
|
| 47 |
+
|
| 48 |
+
if path:
|
| 49 |
+
for i in range(len(path)-1):
|
| 50 |
+
x1,y1 = path[i]; x2,y2 = path[i+1]
|
| 51 |
+
draw.line([(x1*cell+cell//2,y1*cell+cell//2),(x2*cell+cell//2,y2*cell+cell//2)], fill=color_dict.get('path', 'red'), width=3)
|
| 52 |
+
if save_path:
|
| 53 |
+
try:
|
| 54 |
+
img.save(save_path)
|
| 55 |
+
logging.info("Saved rectangular maze image to %s", save_path)
|
| 56 |
+
except Exception:
|
| 57 |
+
logging.exception("Failed to save image to %s", save_path)
|
| 58 |
+
return img
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def draw_hex_maze(maze, path, cell_size=45, rotation_degrees=0, save_path=None, show_doors=False, color_dict={'wall':'black', 'floor':'white', 'start':'green', 'door':'#B0B0B0', 'path':'red'}):
|
| 62 |
+
"""
|
| 63 |
+
Draw a hex maze (walls only where no passage) and optional path using PIL.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
maze (dict): mapping (q, r) -> list of (dq, dr) neighbor offsets.
|
| 67 |
+
path (list[tuple[int,int]]): sequence of axial coordinates to draw as red line.
|
| 68 |
+
cell_size (int): approximate pixel size of a hex cell.
|
| 69 |
+
save_path (str|None): if given, save image to file instead of showing.
|
| 70 |
+
rotation_degrees (float): rotation of hexagons in degrees (default 0 = flat-topped).
|
| 71 |
+
save_path (str|None): if provided, save the rendered image to this path; if None, show the image.
|
| 72 |
+
show_doors (bool): if True, draw thin grey outlines around each hex to show doors.
|
| 73 |
+
color_dict (dict): optional dict to specify colors for 'wall', 'floor', 'start', and 'door'.
|
| 74 |
+
"""
|
| 75 |
+
rot = math.radians(rotation_degrees)
|
| 76 |
+
if not maze: return
|
| 77 |
+
r_max = max((max(abs(q),abs(r)) for q,r in maze), default=5)
|
| 78 |
+
w = int(3*(r_max+2)*cell_size)
|
| 79 |
+
h = int(math.sqrt(3)*2*(r_max+2)*cell_size)
|
| 80 |
+
img = Image.new('RGB', (w,h), color_dict.get('floor', 'white'))
|
| 81 |
+
d = ImageDraw.Draw(img)
|
| 82 |
+
dirs = [(1,0),(0,1),(-1,1),(-1,0),(0,-1),(1,-1)]
|
| 83 |
+
|
| 84 |
+
if show_doors:
|
| 85 |
+
for q,r in maze: # light grey outlines
|
| 86 |
+
cx = cell_size*1.5*q + w//2
|
| 87 |
+
cy = cell_size*math.sqrt(3)*(r + q/2) + h//2
|
| 88 |
+
pts = [(cx + cell_size*math.cos(rot + i*math.pi/3),
|
| 89 |
+
cy + cell_size*math.sin(rot + i*math.pi/3)) for i in range(6)]
|
| 90 |
+
d.polygon(pts, outline=color_dict.get('door', '#B0B0B0'), width=2)
|
| 91 |
+
# fill start hex (0,0) with green floor so the starting cell is visibly marked
|
| 92 |
+
if (0, 0) in maze:
|
| 93 |
+
cx = w//2
|
| 94 |
+
cy = h//2
|
| 95 |
+
start_pts = [(cx + cell_size*math.cos(rot + i*math.pi/3),
|
| 96 |
+
cy + cell_size*math.sin(rot + i*math.pi/3)) for i in range(6)]
|
| 97 |
+
d.polygon(start_pts, fill=color_dict.get('start', 'green'))
|
| 98 |
+
|
| 99 |
+
for q,r in maze: # black blocked walls
|
| 100 |
+
cx = cell_size*1.5*q + w//2
|
| 101 |
+
cy = cell_size*math.sqrt(3)*(r + q/2) + h//2
|
| 102 |
+
for i,(dq,dr) in enumerate(dirs):
|
| 103 |
+
if (dq,dr) not in maze.get((q,r),[]):
|
| 104 |
+
a = rot + i*math.pi/3
|
| 105 |
+
x1 = cx + cell_size*math.cos(a)
|
| 106 |
+
y1 = cy + cell_size*math.sin(a)
|
| 107 |
+
x2 = cx + cell_size*math.cos(a+math.pi/3)
|
| 108 |
+
y2 = cy + cell_size*math.sin(a+math.pi/3)
|
| 109 |
+
d.line([(x1,y1),(x2,y2)], fill=color_dict.get('wall', 'black'), width=6)
|
| 110 |
+
|
| 111 |
+
# force clear exit to outside on end cell (outer side with no neighbor)
|
| 112 |
+
end = max(maze, key=lambda p: abs(p[0])+abs(p[1])+abs(p[0]+p[1]))
|
| 113 |
+
cx = cell_size*1.5*end[0] + w//2
|
| 114 |
+
cy = cell_size*math.sqrt(3)*(end[1] + end[0]/2) + h//2
|
| 115 |
+
for i,(dq,dr) in enumerate(dirs):
|
| 116 |
+
neigh = (end[0]+dq, end[1]+dr)
|
| 117 |
+
if neigh not in maze: # outer side
|
| 118 |
+
a = rot + i*math.pi/3
|
| 119 |
+
x1 = cx + cell_size*math.cos(a)
|
| 120 |
+
y1 = cy + cell_size*math.sin(a)
|
| 121 |
+
x2 = cx + cell_size*math.cos(a+math.pi/3)
|
| 122 |
+
y2 = cy + cell_size*math.sin(a+math.pi/3)
|
| 123 |
+
d.line([(x1,y1),(x2,y2)], fill=color_dict.get('floor', 'white'), width=26)
|
| 124 |
+
break
|
| 125 |
+
|
| 126 |
+
if path:
|
| 127 |
+
pts = [(cell_size*1.5*q + w//2, cell_size*math.sqrt(3)*(r + q/2) + h//2) for q,r in path]
|
| 128 |
+
d.line(pts, fill=color_dict.get('path', 'red'), width=7)
|
| 129 |
+
|
| 130 |
+
if save_path:
|
| 131 |
+
try:
|
| 132 |
+
img.save(save_path)
|
| 133 |
+
logging.info("Saved hex maze image to %s", save_path)
|
| 134 |
+
except Exception:
|
| 135 |
+
logging.exception("Failed to save image to %s", save_path)
|
| 136 |
+
return img
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def draw_tri_maze(maze, path, cell=40, save_path=None, show_doors=False, color_dict={'wall':'black', 'floor':'white', 'start':'green', 'door':'#B0B0B0', 'path':'red'}):
|
| 140 |
+
"""
|
| 141 |
+
Draw a triangular-grid maze using PIL.
|
| 142 |
+
|
| 143 |
+
Maze format expected:
|
| 144 |
+
maze (dict): mapping (x, y) -> list of (dx, dy) neighbor offsets that are open.
|
| 145 |
+
Each cell is an equilateral triangle. Coordinates are grid indices (x:col, y:row).
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
maze (dict): triangle adjacency mapping.
|
| 149 |
+
path (list[tuple[int,int]]): list of (x,y) cells forming a path.
|
| 150 |
+
cell (int): side length of each triangle in pixels.
|
| 151 |
+
save_path (str|None): save to file if provided, otherwise show.
|
| 152 |
+
show_doors (bool): if True draw thin grey outlines around each triangle.
|
| 153 |
+
color_dict (dict): optional dict to specify colors for 'wall', 'floor', 'start', 'door', and 'path'.
|
| 154 |
+
"""
|
| 155 |
+
if not maze:
|
| 156 |
+
return
|
| 157 |
+
# determine bounds
|
| 158 |
+
xs = [p[0] for p in maze]
|
| 159 |
+
ys = [p[1] for p in maze]
|
| 160 |
+
min_x, max_x = min(xs), max(xs)
|
| 161 |
+
min_y, max_y = min(ys), max(ys)
|
| 162 |
+
w = max_x - min_x + 1
|
| 163 |
+
h = max_y - min_y + 1
|
| 164 |
+
|
| 165 |
+
side = cell
|
| 166 |
+
h_tri = side * math.sqrt(3) / 2.0
|
| 167 |
+
offset_x = side # margin
|
| 168 |
+
offset_y = side
|
| 169 |
+
|
| 170 |
+
img_w = int(offset_x*2 + (w-1) * (side/2.0) + side + 0.5)
|
| 171 |
+
img_h = int(offset_y*2 + h * h_tri + 0.5)
|
| 172 |
+
img = Image.new('RGB', (img_w, img_h), color_dict.get('floor', 'white'))
|
| 173 |
+
d = ImageDraw.Draw(img)
|
| 174 |
+
|
| 175 |
+
def cell_vertices(x,y):
|
| 176 |
+
cx = offset_x + (x - min_x) * (side/2.0)
|
| 177 |
+
cy = offset_y + (y - min_y) * h_tri
|
| 178 |
+
up = ((x + y) % 2 == 0)
|
| 179 |
+
if up:
|
| 180 |
+
v0 = (cx, cy)
|
| 181 |
+
v1 = (cx - side/2.0, cy + h_tri)
|
| 182 |
+
v2 = (cx + side/2.0, cy + h_tri)
|
| 183 |
+
else:
|
| 184 |
+
v0 = (cx, cy + h_tri)
|
| 185 |
+
v1 = (cx - side/2.0, cy)
|
| 186 |
+
v2 = (cx + side/2.0, cy)
|
| 187 |
+
return up, v0, v1, v2
|
| 188 |
+
|
| 189 |
+
# fill start triangle (0,0) with green floor so the starting cell is visibly marked
|
| 190 |
+
if (0, 0) in maze:
|
| 191 |
+
_, v0_0, v1_0, v2_0 = cell_vertices(0, 0)
|
| 192 |
+
d.polygon([v0_0, v1_0, v2_0], fill=color_dict.get('start', 'green'))
|
| 193 |
+
|
| 194 |
+
# optional thin grey outlines for each triangle
|
| 195 |
+
if show_doors:
|
| 196 |
+
for (x,y) in maze:
|
| 197 |
+
up, v0, v1, v2 = cell_vertices(x,y)
|
| 198 |
+
pts = [v0, v1, v2]
|
| 199 |
+
d.polygon(pts, outline=color_dict.get('door', '#B0B0B0'), width=1)
|
| 200 |
+
|
| 201 |
+
# draw black walls where no passage
|
| 202 |
+
for (x,y) in maze:
|
| 203 |
+
up, v0, v1, v2 = cell_vertices(x,y)
|
| 204 |
+
# map direction offsets to edge vertex pairs
|
| 205 |
+
if up:
|
| 206 |
+
edge_map = {
|
| 207 |
+
(-1,0): (v0, v1), # left
|
| 208 |
+
(1,0): (v0, v2), # right
|
| 209 |
+
(0,1): (v1, v2), # down/base
|
| 210 |
+
}
|
| 211 |
+
else:
|
| 212 |
+
edge_map = {
|
| 213 |
+
(-1,0): (v1, v0), # left
|
| 214 |
+
(1,0): (v2, v0), # right
|
| 215 |
+
(0,-1): (v1, v2), # up/base
|
| 216 |
+
}
|
| 217 |
+
for off,verts in edge_map.items():
|
| 218 |
+
if off not in maze.get((x,y), []):
|
| 219 |
+
d.line([verts[0], verts[1]], fill=color_dict.get('wall', 'black'), width=6)
|
| 220 |
+
|
| 221 |
+
# force clear exit on end cell (outer side with no neighbor)
|
| 222 |
+
# choose end as max by x+y
|
| 223 |
+
end = max(maze, key=lambda p: p[0] + p[1])
|
| 224 |
+
ex, ey = end
|
| 225 |
+
up, v0, v1, v2 = cell_vertices(ex, ey)
|
| 226 |
+
if up:
|
| 227 |
+
dir_order = [(-1,0),(1,0),(0,1)]
|
| 228 |
+
edge_order = [(v0,v1),(v0,v2),(v1,v2)]
|
| 229 |
+
else:
|
| 230 |
+
dir_order = [(-1,0),(1,0),(0,-1)]
|
| 231 |
+
edge_order = [(v1,v0),(v2,v0),(v1,v2)]
|
| 232 |
+
for off,edge in zip(dir_order, edge_order):
|
| 233 |
+
neigh = (ex + off[0], ey + off[1])
|
| 234 |
+
if neigh not in maze:
|
| 235 |
+
d.line([edge[0], edge[1]], fill=color_dict.get('floor', 'white'), width=26)
|
| 236 |
+
break
|
| 237 |
+
|
| 238 |
+
# draw path if provided (connect centroids)
|
| 239 |
+
if path:
|
| 240 |
+
pts = []
|
| 241 |
+
for x,y in path:
|
| 242 |
+
up, v0, v1, v2 = cell_vertices(x,y)
|
| 243 |
+
# centroid average of vertices
|
| 244 |
+
cx = (v0[0] + v1[0] + v2[0]) / 3.0
|
| 245 |
+
cy = (v0[1] + v1[1] + v2[1]) / 3.0
|
| 246 |
+
pts.append((cx, cy))
|
| 247 |
+
d.line(pts, fill=color_dict.get('path', 'red'), width=7)
|
| 248 |
+
|
| 249 |
+
if save_path:
|
| 250 |
+
try:
|
| 251 |
+
img.save(save_path)
|
| 252 |
+
logging.info("Saved triangular maze image to %s", save_path)
|
| 253 |
+
except Exception:
|
| 254 |
+
logging.exception("Failed to save tri image to %s", save_path)
|
| 255 |
+
return img
|
utils/mazlib/grid.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
from collections import deque
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def generate_maze(w, h, seed=0):
|
| 6 |
+
"""
|
| 7 |
+
Generate a randomized perfect maze using depth-first search.
|
| 8 |
+
|
| 9 |
+
Args:
|
| 10 |
+
w (int): width of the maze in cells (number of columns).
|
| 11 |
+
h (int): height of the maze in cells (number of rows).
|
| 12 |
+
|
| 13 |
+
Returns:
|
| 14 |
+
list[list[int]]: 2D grid representing the maze where 1 indicates a wall
|
| 15 |
+
and 0 indicates an open cell. The start is at (0,0) and the goal is at (w-1,h-1).
|
| 16 |
+
"""
|
| 17 |
+
maze = [[1]*w for _ in range(h)]
|
| 18 |
+
rng = random.Random(seed) if seed else None
|
| 19 |
+
|
| 20 |
+
def dfs(x, y):
|
| 21 |
+
maze[y][x] = 0
|
| 22 |
+
dirs = [(0,2),(2,0),(0,-2),(-2,0)]
|
| 23 |
+
(rng.shuffle(dirs) if rng is not None else random.shuffle(dirs))
|
| 24 |
+
for dx,dy in dirs:
|
| 25 |
+
nx,ny = x+dx,y+dy
|
| 26 |
+
if 0<=nx<w and 0<=ny<h and maze[ny][nx]:
|
| 27 |
+
maze[y+dy//2][x+dx//2] = 0
|
| 28 |
+
dfs(nx,ny)
|
| 29 |
+
# occasional extra random connection (10%) to introduce loops and increase difficulty
|
| 30 |
+
if (rng.random() if rng is not None else random.random()) < 0.1:
|
| 31 |
+
extra_dirs = [d for d in dirs if d != (dx,dy)]
|
| 32 |
+
(rng.shuffle(extra_dirs) if rng is not None else random.shuffle(extra_dirs))
|
| 33 |
+
for edx, edy in extra_dirs:
|
| 34 |
+
enx, eny = x+edx, y+edy
|
| 35 |
+
# ensure within bounds and target already open
|
| 36 |
+
if 0<=enx<w and 0<=eny<h and maze[eny][enx]==0:
|
| 37 |
+
# open the wall between (x,y) and (enx,eny) if it's closed
|
| 38 |
+
wx, wy = x+edx//2, y+edy//2
|
| 39 |
+
if maze[wy][wx]:
|
| 40 |
+
maze[wy][wx] = 0
|
| 41 |
+
break
|
| 42 |
+
|
| 43 |
+
dfs(0,0)
|
| 44 |
+
maze[0][0] = maze[h-1][w-1] = 0
|
| 45 |
+
# ensure starting cell has one extra open neighbour if possible (not an external wall)
|
| 46 |
+
dirs = [(0,2),(2,0),(0,-2),(-2,0)]
|
| 47 |
+
random.shuffle(dirs)
|
| 48 |
+
for dx,dy in dirs:
|
| 49 |
+
nx,ny = dx,dy
|
| 50 |
+
if 0<=nx<w and 0<=ny<h and maze[ny][nx]==1:
|
| 51 |
+
# open the intermediate wall and the target cell
|
| 52 |
+
maze[dy//2][dx//2] = 0
|
| 53 |
+
maze[ny][nx] = 0
|
| 54 |
+
break
|
| 55 |
+
return maze
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def generate_maze_iterative(w, h, seed=0):
|
| 59 |
+
"""
|
| 60 |
+
Generate a randomized perfect maze using an explicit stack (iterative DFS).
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
w (int): width of the maze in cells (number of columns).
|
| 64 |
+
h (int): height of the maze in cells (number of rows).
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
list[list[int]]: 2D grid representing the maze where 1 indicates a wall
|
| 68 |
+
and 0 indicates an open cell.
|
| 69 |
+
"""
|
| 70 |
+
maze = [[1]*w for _ in range(h)]
|
| 71 |
+
rng = random.Random(seed) if seed else None
|
| 72 |
+
stack = [(0, 0)]
|
| 73 |
+
maze[0][0] = 0
|
| 74 |
+
dirs = [(0,2),(2,0),(0,-2),(-2,0)]
|
| 75 |
+
while stack:
|
| 76 |
+
x,y = stack[-1]
|
| 77 |
+
(rng.shuffle(dirs) if rng is not None else random.shuffle(dirs))
|
| 78 |
+
for dx,dy in dirs:
|
| 79 |
+
nx,ny = x+dx,y+dy
|
| 80 |
+
if 0<=nx<w and 0<=ny<h and maze[ny][nx]:
|
| 81 |
+
maze[y+dy//2][x+dx//2] = 0
|
| 82 |
+
maze[ny][nx] = 0
|
| 83 |
+
stack.append((nx,ny))
|
| 84 |
+
# occasional extra random connection (10%) to introduce loops and increase difficulty
|
| 85 |
+
if (rng.random() if rng is not None else random.random()) < 0.1:
|
| 86 |
+
extra_dirs = [d for d in dirs if d != (dx,dy)]
|
| 87 |
+
(rng.shuffle(extra_dirs) if rng is not None else random.shuffle(extra_dirs))
|
| 88 |
+
for edx, edy in extra_dirs:
|
| 89 |
+
enx, eny = x+edx, y+edy
|
| 90 |
+
if 0<=enx<w and 0<=eny<h and maze[eny][enx]==0:
|
| 91 |
+
wx, wy = x+edx//2, y+edy//2
|
| 92 |
+
if maze[wy][wx]:
|
| 93 |
+
maze[wy][wx] = 0
|
| 94 |
+
break
|
| 95 |
+
break
|
| 96 |
+
else:
|
| 97 |
+
stack.pop()
|
| 98 |
+
maze[0][0] = maze[h-1][w-1] = 0
|
| 99 |
+
# ensure starting cell has one extra open neighbour if possible (not an external wall)
|
| 100 |
+
dirs = [(0,2),(2,0),(0,-2),(-2,0)]
|
| 101 |
+
(rng.shuffle(dirs) if rng is not None else random.shuffle(dirs))
|
| 102 |
+
for dx,dy in dirs:
|
| 103 |
+
nx,ny = 0+dx, 0+dy
|
| 104 |
+
if 0<=nx<w and 0<=ny<h and maze[ny][nx]==1:
|
| 105 |
+
# open the intermediate wall and the target cell
|
| 106 |
+
maze[dy//2][dx//2] = 0
|
| 107 |
+
maze[ny][nx] = 0
|
| 108 |
+
break
|
| 109 |
+
return maze
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def solve_maze(maze):
|
| 113 |
+
"""
|
| 114 |
+
Solve a maze grid using breadth-first search to produce the shortest path.
|
| 115 |
+
|
| 116 |
+
Args:
|
| 117 |
+
maze (list[list[int]]): 2D grid where 1 indicates a wall and 0 indicates open space.
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
list[tuple[int,int]]: list of (x, y) coordinates from start (0,0) to goal (w-1,h-1).
|
| 121 |
+
"""
|
| 122 |
+
h,w = len(maze),len(maze[0])
|
| 123 |
+
q = deque([(0,0)])
|
| 124 |
+
parent = {(0,0):None}
|
| 125 |
+
while q:
|
| 126 |
+
x,y = q.popleft()
|
| 127 |
+
if (x,y)==(w-1,h-1):
|
| 128 |
+
break
|
| 129 |
+
for dx,dy in [(0,1),(1,0),(0,-1),(-1,0)]:
|
| 130 |
+
nx,ny = x+dx,y+dy
|
| 131 |
+
if 0<=nx<w and 0<=ny<h and maze[ny][nx]==0 and (nx,ny) not in parent:
|
| 132 |
+
parent[(nx,ny)] = (x,y)
|
| 133 |
+
q.append((nx,ny))
|
| 134 |
+
path = []
|
| 135 |
+
at = (w-1,h-1)
|
| 136 |
+
while at:
|
| 137 |
+
path.append(at)
|
| 138 |
+
at = parent.get(at)
|
| 139 |
+
return path[::-1]
|
utils/mazlib/hex.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
from collections import deque
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def generate_hex_maze(radius, seed=0):
|
| 6 |
+
"""Backwards-compatible wrapper that uses the iterative implementation."""
|
| 7 |
+
return generate_hex_maze_iterative(radius, seed=seed)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def generate_hex_maze_iterative(radius, seed=0):
|
| 11 |
+
"""
|
| 12 |
+
Generate a hexagonal maze using an explicit stack (iterative DFS) on axial coords.
|
| 13 |
+
|
| 14 |
+
Args:
|
| 15 |
+
radius (int): maximum radius (in hex steps) from the origin to include.
|
| 16 |
+
|
| 17 |
+
Returns:
|
| 18 |
+
dict: mapping (q, r) -> list of (dq, dr) neighbor offsets for open edges.
|
| 19 |
+
"""
|
| 20 |
+
maze = {}
|
| 21 |
+
visited = set()
|
| 22 |
+
stack = [(0, 0)]
|
| 23 |
+
visited.add((0, 0))
|
| 24 |
+
dirs = [(1,0),(0,1),(-1,1),(-1,0),(0,-1),(1,-1)]
|
| 25 |
+
rng = random.Random(seed) if seed else None
|
| 26 |
+
while stack:
|
| 27 |
+
q,r = stack[-1]
|
| 28 |
+
directions = dirs[:] # ← fixed: copy before shuffle
|
| 29 |
+
(rng.shuffle(directions) if rng is not None else random.shuffle(directions))
|
| 30 |
+
moved = False
|
| 31 |
+
for dq,dr in directions:
|
| 32 |
+
nq,nr = q+dq,r+dr
|
| 33 |
+
if (nq,nr) not in visited and max(abs(nq),abs(nr),abs(nq+nr)) <= radius:
|
| 34 |
+
visited.add((nq,nr))
|
| 35 |
+
stack.append((nq,nr))
|
| 36 |
+
maze.setdefault((q,r),[]).append((dq,dr))
|
| 37 |
+
maze.setdefault((nq,nr),[]).append((-dq,-dr))
|
| 38 |
+
# occasional extra random connection (10%) to introduce loops and increase difficulty
|
| 39 |
+
if (rng.random() if rng is not None else random.random()) < 0.1:
|
| 40 |
+
# prefer connecting to an already visited neighbor so we create a loop
|
| 41 |
+
extra_dirs = [d for d in dirs if d != (dq,dr)]
|
| 42 |
+
(rng.shuffle(extra_dirs) if rng is not None else random.shuffle(extra_dirs))
|
| 43 |
+
for edq,edr in extra_dirs:
|
| 44 |
+
enq,enr = q+edq, r+edr
|
| 45 |
+
# only connect to already visited neighbours to avoid creating isolated cells
|
| 46 |
+
if (enq,enr) in visited and (edq,edr) not in maze.get((q,r), []):
|
| 47 |
+
maze.setdefault((q,r),[]).append((edq,edr))
|
| 48 |
+
maze.setdefault((enq,enr),[]).append((-edq,-edr))
|
| 49 |
+
break
|
| 50 |
+
moved = True
|
| 51 |
+
break
|
| 52 |
+
if not moved:
|
| 53 |
+
stack.pop()
|
| 54 |
+
# Ensure the starting hex has two additional neighbor offsets if possible.
|
| 55 |
+
# This makes the maze entry/exit from the center more open and increases difficulty.
|
| 56 |
+
# (If there are not enough valid neighbours within the radius, add as many as possible.)
|
| 57 |
+
if (0, 0) not in maze:
|
| 58 |
+
maze.setdefault((0, 0), [])
|
| 59 |
+
# choose up to two directions not already open from the start
|
| 60 |
+
available = [d for d in dirs if d not in maze.get((0, 0), []) and max(abs(d[0]), abs(d[1]), abs(d[0] + d[1])) <= radius]
|
| 61 |
+
(rng.shuffle(available) if rng is not None else random.shuffle(available))
|
| 62 |
+
for dq, dr in available[:2]:
|
| 63 |
+
nq, nr = dq, dr
|
| 64 |
+
# add reciprocal entries even if the neighbour wasn't previously in the maze
|
| 65 |
+
if (dq, dr) not in maze.get((0, 0), []):
|
| 66 |
+
maze.setdefault((0, 0), []).append((dq, dr))
|
| 67 |
+
maze.setdefault((nq, nr), []).append((-dq, -dr))
|
| 68 |
+
|
| 69 |
+
return maze
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def solve_hex_maze(maze):
|
| 73 |
+
"""
|
| 74 |
+
Solve a hex maze (produced by generate_hex_maze) using breadth-first search.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
maze (dict): mapping (q, r) -> list of (dq, dr) neighbor offsets.
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
list[tuple[int,int]]: sequence of axial coordinates from the origin to the
|
| 81 |
+
farthest cell (heuristic goal) in the generated maze. Returns empty list
|
| 82 |
+
for an empty maze.
|
| 83 |
+
"""
|
| 84 |
+
if not maze:
|
| 85 |
+
return []
|
| 86 |
+
start = (0,0)
|
| 87 |
+
end = max(maze, key=lambda p: abs(p[0])+abs(p[1])+abs(p[0]+p[1]))
|
| 88 |
+
queue = deque([start])
|
| 89 |
+
parent = {start: None}
|
| 90 |
+
while queue:
|
| 91 |
+
curr = queue.popleft()
|
| 92 |
+
if curr == end:
|
| 93 |
+
break
|
| 94 |
+
for dq,dr in maze.get(curr,[]):
|
| 95 |
+
neigh = (curr[0]+dq, curr[1]+dr)
|
| 96 |
+
if neigh not in parent:
|
| 97 |
+
parent[neigh] = curr
|
| 98 |
+
queue.append(neigh)
|
| 99 |
+
path = []
|
| 100 |
+
at = end
|
| 101 |
+
while at:
|
| 102 |
+
path.append(at)
|
| 103 |
+
at = parent.get(at)
|
| 104 |
+
return path[::-1]
|
utils/mazlib/requirements.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Pillow>=9.0
|
utils/mazlib/tri.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Triangle-grid maze helpers.
|
| 2 |
+
|
| 3 |
+
This module implements a simple triangular-grid maze generator (randomized
|
| 4 |
+
depth-first search), a BFS solver and a drawing helper that renders the
|
| 5 |
+
triangular tiling with walls and optional thin grey outlines for doors.
|
| 6 |
+
|
| 7 |
+
Maze representation:
|
| 8 |
+
dict mapping (x,y) -> list of (dx,dy) neighbor offsets that are open.
|
| 9 |
+
|
| 10 |
+
Coordinates:
|
| 11 |
+
x: column index, 0..w-1
|
| 12 |
+
y: row index, 0..h-1
|
| 13 |
+
|
| 14 |
+
Triangle orientation alternates with parity: up when (x+y) % 2 == 0,
|
| 15 |
+
otherwise down.
|
| 16 |
+
"""
|
| 17 |
+
import random
|
| 18 |
+
from collections import deque
|
| 19 |
+
from .draw import draw_tri_maze as _draw_tri
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _neighbor_offsets(x, y, w, h):
|
| 23 |
+
"""Return valid neighbor offsets for triangle at (x,y) based on orientation."""
|
| 24 |
+
up = ((x + y) % 2 == 0)
|
| 25 |
+
if up:
|
| 26 |
+
candidates = [(-1, 0), (1, 0), (0, 1)]
|
| 27 |
+
else:
|
| 28 |
+
candidates = [(-1, 0), (1, 0), (0, -1)]
|
| 29 |
+
valid = []
|
| 30 |
+
for dx, dy in candidates:
|
| 31 |
+
nx, ny = x + dx, y + dy
|
| 32 |
+
if 0 <= nx < w and 0 <= ny < h:
|
| 33 |
+
valid.append((dx, dy))
|
| 34 |
+
return valid
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def generate_tri_maze(w, h=None, seed=0):
|
| 38 |
+
"""Generate a triangular maze using randomized DFS.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
w (int): number of columns.
|
| 42 |
+
h (int|None): number of rows (if None, equals w).
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
dict: mapping (x,y) -> list of neighbor offsets (dx,dy) that are open.
|
| 46 |
+
"""
|
| 47 |
+
if h is None:
|
| 48 |
+
h = w
|
| 49 |
+
# initialize empty adjacency
|
| 50 |
+
maze = {(x, y): [] for x in range(w) for y in range(h)}
|
| 51 |
+
rng = random.Random(seed) if seed else None
|
| 52 |
+
visited = set()
|
| 53 |
+
|
| 54 |
+
stack = [(0, 0)]
|
| 55 |
+
visited.add((0, 0))
|
| 56 |
+
|
| 57 |
+
while stack:
|
| 58 |
+
x, y = stack[-1]
|
| 59 |
+
offs = _neighbor_offsets(x, y, w, h)
|
| 60 |
+
# filter to unvisited neighbours
|
| 61 |
+
nbrs = [(dx, dy) for dx, dy in offs if (x + dx, y + dy) not in visited]
|
| 62 |
+
if nbrs:
|
| 63 |
+
dx, dy = (rng.choice(nbrs) if rng is not None else random.choice(nbrs))
|
| 64 |
+
nx, ny = x + dx, y + dy
|
| 65 |
+
# carve passage both ways (store offsets)
|
| 66 |
+
maze[(x, y)].append((dx, dy))
|
| 67 |
+
maze[(nx, ny)].append((-dx, -dy))
|
| 68 |
+
visited.add((nx, ny))
|
| 69 |
+
stack.append((nx, ny))
|
| 70 |
+
# occasional extra random connection (10%) to introduce loops and increase difficulty
|
| 71 |
+
if (rng.random() if rng is not None else random.random()) < 0.1:
|
| 72 |
+
extra_offs = [o for o in offs if o != (dx, dy)]
|
| 73 |
+
(rng.shuffle(extra_offs) if rng is not None else random.shuffle(extra_offs))
|
| 74 |
+
for edx, edy in extra_offs:
|
| 75 |
+
enx, eny = x + edx, y + edy
|
| 76 |
+
# connect only to already visited neighbours to avoid isolating cells
|
| 77 |
+
if (enx, eny) in visited and (edx, edy) not in maze.get((x, y), []):
|
| 78 |
+
maze[(x, y)].append((edx, edy))
|
| 79 |
+
maze[(enx, eny)].append((-edx, -edy))
|
| 80 |
+
break
|
| 81 |
+
else:
|
| 82 |
+
stack.pop()
|
| 83 |
+
# ensure the starting triangle (0,0) has one additional neighbour offset if possible
|
| 84 |
+
start_offs = _neighbor_offsets(0, 0, w, h)
|
| 85 |
+
candidates = [o for o in start_offs if o not in maze.get((0, 0), [])]
|
| 86 |
+
if candidates:
|
| 87 |
+
edx, edy = (rng.choice(candidates) if rng is not None else random.choice(candidates))
|
| 88 |
+
nx, ny = 0 + edx, 0 + edy
|
| 89 |
+
if (edx, edy) not in maze.get((0, 0), []):
|
| 90 |
+
maze[(0, 0)].append((edx, edy))
|
| 91 |
+
maze[(nx, ny)].append((-edx, -edy))
|
| 92 |
+
|
| 93 |
+
return maze
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def generate_tri_maze_iterative(w, h=None, seed=0):
|
| 97 |
+
"""Alias for generate_tri_maze; generation is already iterative.
|
| 98 |
+
|
| 99 |
+
Kept for API symmetry with other generators.
|
| 100 |
+
"""
|
| 101 |
+
return generate_tri_maze(w, h, seed=seed)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def solve_tri_maze(maze):
|
| 105 |
+
"""Solve a triangular maze returning a path from start to end.
|
| 106 |
+
|
| 107 |
+
Start is assumed at (0,0). End is chosen as the cell with the maximum
|
| 108 |
+
x+y (bottom-right-like) so it aligns with the drawing and CLI examples.
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
list[(x,y)]: path from start to end (inclusive) or empty list if none.
|
| 112 |
+
"""
|
| 113 |
+
if not maze:
|
| 114 |
+
return []
|
| 115 |
+
start = (0, 0)
|
| 116 |
+
end = max(maze, key=lambda p: p[0] + p[1])
|
| 117 |
+
|
| 118 |
+
q = deque([start])
|
| 119 |
+
parent = {start: None}
|
| 120 |
+
while q:
|
| 121 |
+
cur = q.popleft()
|
| 122 |
+
if cur == end:
|
| 123 |
+
break
|
| 124 |
+
x, y = cur
|
| 125 |
+
for dx, dy in maze.get((x, y), []):
|
| 126 |
+
nx, ny = x + dx, y + dy
|
| 127 |
+
if (nx, ny) not in parent:
|
| 128 |
+
parent[(nx, ny)] = (x, y)
|
| 129 |
+
q.append((nx, ny))
|
| 130 |
+
if end not in parent:
|
| 131 |
+
return []
|
| 132 |
+
# reconstruct path
|
| 133 |
+
path = []
|
| 134 |
+
at = end
|
| 135 |
+
while at is not None:
|
| 136 |
+
path.append(at)
|
| 137 |
+
at = parent.get(at)
|
| 138 |
+
return path[::-1]
|
utils/storage.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# Storage Module (`
|
| 2 |
|
| 3 |
The `storage.py` module provides helper functions for:
|
| 4 |
- Generating permalinks for 3D viewer projects.
|
|
@@ -12,7 +12,7 @@ The `storage.py` module provides helper functions for:
|
|
| 12 |
### 1. `generate_permalink(valid_files, base_url_external, permalink_viewer_url="surn-3d-viewer.hf.space")`
|
| 13 |
- **Purpose:**
|
| 14 |
Given a list of file paths, it looks for exactly one model file (with an extension defined in `model_extensions`) and exactly two image files (extensions defined in `image_extensions`). If the criteria are met, it returns a permalink URL built from the base URL and query parameters.
|
| 15 |
-
- **Usage Example:**from
|
| 16 |
|
| 17 |
valid_files = [
|
| 18 |
"models/3d_model.glb",
|
|
@@ -26,7 +26,7 @@ if permalink:
|
|
| 26 |
### 2. `generate_permalink_from_urls(model_url, hm_url, img_url, permalink_viewer_url="surn-3d-viewer.hf.space")`
|
| 27 |
- **Purpose:**
|
| 28 |
Constructs a permalink URL by combining individual URLs for a 3D model (`model_url`), height map (`hm_url`), and image (`img_url`) into a single URL with corresponding query parameters.
|
| 29 |
-
- **Usage Example:**from
|
| 30 |
|
| 31 |
model_url = "https://example.com/model.glb"
|
| 32 |
hm_url = "https://example.com/heightmap.png"
|
|
@@ -48,7 +48,7 @@ print("Generated Permalink:", permalink)
|
|
| 48 |
- If no valid files are provided, it returns an empty list `[]` (this case should ideally also return the dictionary with empty/None values for consistency, but currently returns `[]` as per the code).
|
| 49 |
- **Usage Example:**
|
| 50 |
|
| 51 |
-
**a. Uploading with permalink creation:**from
|
| 52 |
|
| 53 |
files_for_permalink = [
|
| 54 |
"local/path/to/model.glb",
|
|
@@ -75,7 +75,7 @@ elif upload_result: # Check if list is not empty
|
|
| 75 |
print(f" Response: {res}, Link: {link}")
|
| 76 |
else:
|
| 77 |
print("No files uploaded or error occurred.")
|
| 78 |
-
**b. Uploading without permalink creation (or if conditions for permalink are not met):**from
|
| 79 |
|
| 80 |
files_individual = [
|
| 81 |
"local/path/to/another_model.obj",
|
|
@@ -113,8 +113,8 @@ If both short URL ID and full URL are provided, it checks consistency and either
|
|
| 113 |
- **Returns:** A tuple `(status_message, result_url)`, where `status_message` indicates the outcome (e.g., `"success_retrieved_full"`, `"created_short"`) and `result_url` is the relevant URL (full or short ID).
|
| 114 |
- **Usage Examples:**
|
| 115 |
|
| 116 |
-
**a. Convert a full URL into a short URL ID:**from
|
| 117 |
-
from
|
| 118 |
|
| 119 |
full_permalink = "https://surn-3d-viewer.hf.space/?3d=https%3A%2F%2Fexample.com%2Fmodel.glb&hm=https%3A%2F%2Fexample.com%2Fheightmap.png&image=https%3A%2F%2Fexample.com%2Fsource.png"
|
| 120 |
|
|
@@ -130,8 +130,67 @@ if status == "created_short" or status == "success_retrieved_short":
|
|
| 130 |
# permalink_viewer_url = "surn-3d-viewer.hf.space" # Or from constants
|
| 131 |
# shareable_short_url = f"https://{permalink_viewer_url}/?sid={short_id}"
|
| 132 |
# print("Shareable Short URL:", shareable_short_url)
|
| 133 |
-
**b. Retrieve the full URL from a short URL ID:**from
|
| 134 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
short_id_to_lookup = "aBcDeFg1" # Example short URL ID
|
| 137 |
|
|
@@ -144,8 +203,9 @@ print("Status:", status)
|
|
| 144 |
if status == "success_retrieved_full":
|
| 145 |
print("Retrieved Full URL:", retrieved_full_url)
|
| 146 |
## Notes
|
| 147 |
-
- **Authentication:** All functions that interact with Hugging Face Hub use the HF API token defined as `HF_API_TOKEN` in `
|
| 148 |
-
- **Constants:** Functions like `gen_full_url` and `upload_files_to_repo` (when creating short links) rely on `HF_REPO_ID` and `SHORTENER_JSON_FILE` from `
|
|
|
|
| 149 |
- **File Types:** Only files with extensions included in `upload_file_types` (a combination of `model_extensions` and `image_extensions` from `modules/constants.py`) are processed by `upload_files_to_repo`.
|
| 150 |
- **Repository Configuration:** When using URL shortening and file uploads, ensure that the specified Hugging Face repository (e.g., defined by `HF_REPO_ID`) exists and that you have write permissions.
|
| 151 |
- **Temporary Directory:** `upload_files_to_repo` temporarily copies files to a local directory (configured by `TMPDIR` in `modules/constants.py`) before uploading.
|
|
|
|
| 1 |
+
# Storage Module (`utils/storage.py`) Usage Guide
|
| 2 |
|
| 3 |
The `storage.py` module provides helper functions for:
|
| 4 |
- Generating permalinks for 3D viewer projects.
|
|
|
|
| 12 |
### 1. `generate_permalink(valid_files, base_url_external, permalink_viewer_url="surn-3d-viewer.hf.space")`
|
| 13 |
- **Purpose:**
|
| 14 |
Given a list of file paths, it looks for exactly one model file (with an extension defined in `model_extensions`) and exactly two image files (extensions defined in `image_extensions`). If the criteria are met, it returns a permalink URL built from the base URL and query parameters.
|
| 15 |
+
- **Usage Example:**from utils.storage import generate_permalink
|
| 16 |
|
| 17 |
valid_files = [
|
| 18 |
"models/3d_model.glb",
|
|
|
|
| 26 |
### 2. `generate_permalink_from_urls(model_url, hm_url, img_url, permalink_viewer_url="surn-3d-viewer.hf.space")`
|
| 27 |
- **Purpose:**
|
| 28 |
Constructs a permalink URL by combining individual URLs for a 3D model (`model_url`), height map (`hm_url`), and image (`img_url`) into a single URL with corresponding query parameters.
|
| 29 |
+
- **Usage Example:**from utils.storage import generate_permalink_from_urls
|
| 30 |
|
| 31 |
model_url = "https://example.com/model.glb"
|
| 32 |
hm_url = "https://example.com/heightmap.png"
|
|
|
|
| 48 |
- If no valid files are provided, it returns an empty list `[]` (this case should ideally also return the dictionary with empty/None values for consistency, but currently returns `[]` as per the code).
|
| 49 |
- **Usage Example:**
|
| 50 |
|
| 51 |
+
**a. Uploading with permalink creation:**from utils.storage import upload_files_to_repo
|
| 52 |
|
| 53 |
files_for_permalink = [
|
| 54 |
"local/path/to/model.glb",
|
|
|
|
| 75 |
print(f" Response: {res}, Link: {link}")
|
| 76 |
else:
|
| 77 |
print("No files uploaded or error occurred.")
|
| 78 |
+
**b. Uploading without permalink creation (or if conditions for permalink are not met):**from utils.storage import upload_files_to_repo
|
| 79 |
|
| 80 |
files_individual = [
|
| 81 |
"local/path/to/another_model.obj",
|
|
|
|
| 113 |
- **Returns:** A tuple `(status_message, result_url)`, where `status_message` indicates the outcome (e.g., `"success_retrieved_full"`, `"created_short"`) and `result_url` is the relevant URL (full or short ID).
|
| 114 |
- **Usage Examples:**
|
| 115 |
|
| 116 |
+
**a. Convert a full URL into a short URL ID:**from utils.storage import gen_full_url
|
| 117 |
+
from utils.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
|
| 118 |
|
| 119 |
full_permalink = "https://surn-3d-viewer.hf.space/?3d=https%3A%2F%2Fexample.com%2Fmodel.glb&hm=https%3A%2F%2Fexample.com%2Fheightmap.png&image=https%3A%2F%2Fexample.com%2Fsource.png"
|
| 120 |
|
|
|
|
| 130 |
# permalink_viewer_url = "surn-3d-viewer.hf.space" # Or from constants
|
| 131 |
# shareable_short_url = f"https://{permalink_viewer_url}/?sid={short_id}"
|
| 132 |
# print("Shareable Short URL:", shareable_short_url)
|
| 133 |
+
**b. Retrieve the full URL from a short URL ID:**from utils.storage import gen_full_url
|
| 134 |
+
from utils.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
|
| 135 |
+
|
| 136 |
+
## 📁 Repository Folder Listing Functions
|
| 137 |
+
|
| 138 |
+
### 5. `_list_repo_folders(repo_id, path_prefix, repo_type="dataset")`
|
| 139 |
+
- **Purpose:**
|
| 140 |
+
List folder names under a given path in a HuggingFace repository. Enables folder-based discovery without index files.
|
| 141 |
+
- **Parameters:**
|
| 142 |
+
- `repo_id` (str): The repository ID on Hugging Face
|
| 143 |
+
- `path_prefix` (str): The path prefix to list folders under
|
| 144 |
+
- `repo_type` (str): Repository type. Default is `"dataset"`.
|
| 145 |
+
- **Returns:** `List[str]` - List of folder names found under the path_prefix.
|
| 146 |
+
- **Usage Example:**
|
| 147 |
+
```python
|
| 148 |
+
from utils.storage import _list_repo_folders
|
| 149 |
+
|
| 150 |
+
# List all date folders in daily leaderboards
|
| 151 |
+
folders = _list_repo_folders("Surn/Wrdler-Data", "games/leaderboards/daily")
|
| 152 |
+
print("Available dates:", folders)
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
### 6. `_list_repo_files_in_folder(repo_id, folder_path, repo_type="dataset")`
|
| 156 |
+
- **Purpose:**
|
| 157 |
+
List file names directly under a folder in a HuggingFace repository.
|
| 158 |
+
- **Parameters:**
|
| 159 |
+
- `repo_id` (str): The repository ID on Hugging Face
|
| 160 |
+
- `folder_path` (str): The folder path to list files under
|
| 161 |
+
- `repo_type` (str): Repository type. Default is `"dataset"`.
|
| 162 |
+
- **Returns:** `List[str]` - List of file names found directly in the folder.
|
| 163 |
+
- **Usage Example:**
|
| 164 |
+
```python
|
| 165 |
+
from utils.storage import _list_repo_files_in_folder
|
| 166 |
+
|
| 167 |
+
files = _list_repo_files_in_folder(
|
| 168 |
+
"Surn/Wrdler-Data",
|
| 169 |
+
"games/leaderboards/daily/2025-01-27/classic-classic-0",
|
| 170 |
+
)
|
| 171 |
+
print("Files:", files)
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
## 🔑 Cryptographic Key Management Functions
|
| 175 |
+
|
| 176 |
+
### 7. `store_issuer_keypair(issuer_id, public_key, private_key, repo_id=None)`
|
| 177 |
+
- **Purpose:**
|
| 178 |
+
Store cryptographic keys for an issuer in a private Hugging Face repository. Private keys are encrypted before storage.
|
| 179 |
+
- **⚠️ IMPORTANT:** This requires a PRIVATE Hugging Face repository. Do not use this with public repositories.
|
| 180 |
+
- **Returns:** `bool` - True if keys were stored successfully, False otherwise.
|
| 181 |
+
|
| 182 |
+
### 8. `get_issuer_keypair(issuer_id, repo_id=None)`
|
| 183 |
+
- **Purpose:**
|
| 184 |
+
Retrieve and decrypt stored cryptographic keys for an issuer.
|
| 185 |
+
- **Returns:** `Tuple[Optional[str], Optional[str]]` - (public_key, private_key) or (None, None) if not found.
|
| 186 |
+
|
| 187 |
+
### 9. `get_verification_methods_registry(repo_id=None)`
|
| 188 |
+
- **Purpose:**
|
| 189 |
+
Retrieve the global verification methods registry containing all registered issuer public keys.
|
| 190 |
+
|
| 191 |
+
### 10. `list_issuer_ids(repo_id=None)`
|
| 192 |
+
- **Purpose:**
|
| 193 |
+
List all issuer IDs that have stored keys in the repository.
|
| 194 |
|
| 195 |
short_id_to_lookup = "aBcDeFg1" # Example short URL ID
|
| 196 |
|
|
|
|
| 203 |
if status == "success_retrieved_full":
|
| 204 |
print("Retrieved Full URL:", retrieved_full_url)
|
| 205 |
## Notes
|
| 206 |
+
- **Authentication:** All functions that interact with Hugging Face Hub use the HF API token defined as `HF_API_TOKEN` in `utils/constants.py`. Ensure this environment variable is correctly set.
|
| 207 |
+
- **Constants:** Functions like `gen_full_url` and `upload_files_to_repo` (when creating short links) rely on `HF_REPO_ID` and `SHORTENER_JSON_FILE` from `utils/constants.py` for the URL shortening feature.
|
| 208 |
+
- **🔐 Private Repository Requirement:** Key management functions require a PRIVATE Hugging Face repository to ensure the security of stored encrypted private keys. Never use these functions with public repositories.
|
| 209 |
- **File Types:** Only files with extensions included in `upload_file_types` (a combination of `model_extensions` and `image_extensions` from `modules/constants.py`) are processed by `upload_files_to_repo`.
|
| 210 |
- **Repository Configuration:** When using URL shortening and file uploads, ensure that the specified Hugging Face repository (e.g., defined by `HF_REPO_ID`) exists and that you have write permissions.
|
| 211 |
- **Temporary Directory:** `upload_files_to_repo` temporarily copies files to a local directory (configured by `TMPDIR` in `modules/constants.py`) before uploading.
|
utils/storage.py
CHANGED
|
@@ -1,15 +1,42 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import os
|
| 4 |
import urllib.parse
|
| 5 |
import tempfile
|
| 6 |
import shutil
|
| 7 |
import json
|
| 8 |
import base64
|
|
|
|
|
|
|
| 9 |
from huggingface_hub import login, upload_folder, hf_hub_download, HfApi
|
| 10 |
from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError
|
| 11 |
-
from utils.constants import
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
# see storage.md for detailed information about the storage module and its functions.
|
| 15 |
|
|
@@ -23,6 +50,7 @@ def generate_permalink(valid_files, base_url_external, permalink_viewer_url="sur
|
|
| 23 |
images_links = []
|
| 24 |
audio_links = []
|
| 25 |
video_links = []
|
|
|
|
| 26 |
for f in valid_files:
|
| 27 |
filename = os.path.basename(f)
|
| 28 |
ext = os.path.splitext(filename)[1].lower()
|
|
@@ -35,6 +63,8 @@ def generate_permalink(valid_files, base_url_external, permalink_viewer_url="sur
|
|
| 35 |
audio_links.append(f"{base_url_external}/{filename}")
|
| 36 |
elif ext in video_extensions:
|
| 37 |
video_links.append(f"{base_url_external}/{filename}")
|
|
|
|
|
|
|
| 38 |
if model_link and len(images_links) == 2:
|
| 39 |
# Construct a permalink to the viewer project with query parameters.
|
| 40 |
permalink_viewer_url = f"https://{permalink_viewer_url}/"
|
|
@@ -94,8 +124,14 @@ def upload_files_to_repo(
|
|
| 94 |
Otherwise:
|
| 95 |
list: A list of tuples (response, permalink) for each file.
|
| 96 |
"""
|
|
|
|
|
|
|
| 97 |
# Log in using the HF API token.
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
valid_files = []
|
| 101 |
permalink_short = None
|
|
@@ -131,13 +167,17 @@ def upload_files_to_repo(
|
|
| 131 |
|
| 132 |
# Batch upload all files in the temporary folder.
|
| 133 |
# Files will be uploaded under the folder (path_in_repo) given by folder_name.
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
# Construct external URLs for each uploaded file.
|
| 143 |
base_url_external = f"https://huggingface.co/datasets/{repo_id}/resolve/main/{folder_name}"
|
|
@@ -160,8 +200,8 @@ def upload_files_to_repo(
|
|
| 160 |
if status in ["created_short", "success_retrieved_short", "exists_match"]:
|
| 161 |
permalink_short = f"https://{permalink_viewer_url}/?sid={short_id}"
|
| 162 |
else: # Shortening failed or conflict not resolved to a usable short_id
|
| 163 |
-
permalink_short = None
|
| 164 |
-
|
| 165 |
|
| 166 |
return {
|
| 167 |
"response": response,
|
|
@@ -178,6 +218,33 @@ def upload_files_to_repo(
|
|
| 178 |
# Otherwise, return individual tuples for each file.
|
| 179 |
return [(response, link) for link in individual_links]
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
def _generate_short_id(length=8):
|
| 182 |
"""Generates a random base64 URL-safe string."""
|
| 183 |
return base64.urlsafe_b64encode(os.urandom(length * 2))[:length].decode('utf-8')
|
|
@@ -197,16 +264,16 @@ def _get_json_from_repo(repo_id, json_file_name, repo_type="dataset"):
|
|
| 197 |
os.remove(json_path) # Clean up downloaded file
|
| 198 |
return data
|
| 199 |
except RepositoryNotFoundError:
|
| 200 |
-
|
| 201 |
return []
|
| 202 |
except EntryNotFoundError:
|
| 203 |
-
|
| 204 |
return []
|
| 205 |
except json.JSONDecodeError:
|
| 206 |
-
|
| 207 |
return []
|
| 208 |
except Exception as e:
|
| 209 |
-
|
| 210 |
return []
|
| 211 |
|
| 212 |
def _upload_json_to_repo(data, repo_id, json_file_name, repo_type="dataset"):
|
|
@@ -232,7 +299,7 @@ def _upload_json_to_repo(data, repo_id, json_file_name, repo_type="dataset"):
|
|
| 232 |
os.remove(tmp_file_path) # Clean up temporary file
|
| 233 |
return True
|
| 234 |
except Exception as e:
|
| 235 |
-
|
| 236 |
if 'tmp_file_path' in locals() and os.path.exists(tmp_file_path):
|
| 237 |
os.remove(tmp_file_path) # Ensure cleanup on error too
|
| 238 |
return False
|
|
@@ -325,3 +392,174 @@ def gen_full_url(short_url=None, full_url=None, repo_id=None, repo_type="dataset
|
|
| 325 |
return "error_upload", None
|
| 326 |
|
| 327 |
return "error_unhandled_case", None # Should not be reached
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face Hub storage helpers.
|
| 2 |
+
|
| 3 |
+
This module is a port of the BattleWords `modules/storage.py` implementation.
|
| 4 |
+
It supports:
|
| 5 |
+
- Batch uploading files via `upload_folder`
|
| 6 |
+
- Generating permalinks to a 3D viewer
|
| 7 |
+
- URL-shortener mapping stored in a JSON file in a HF dataset repo
|
| 8 |
+
- Listing repo folders/files
|
| 9 |
+
- Optional issuer keypair storage (requires PRIVATE repo; demo encryption)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
__version__ = "0.1.6"
|
| 13 |
+
|
| 14 |
import os
|
| 15 |
import urllib.parse
|
| 16 |
import tempfile
|
| 17 |
import shutil
|
| 18 |
import json
|
| 19 |
import base64
|
| 20 |
+
import logging
|
| 21 |
+
from datetime import datetime, timezone
|
| 22 |
from huggingface_hub import login, upload_folder, hf_hub_download, HfApi
|
| 23 |
from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError
|
| 24 |
+
from utils.constants import (
|
| 25 |
+
HF_API_TOKEN,
|
| 26 |
+
upload_file_types,
|
| 27 |
+
model_extensions,
|
| 28 |
+
image_extensions,
|
| 29 |
+
audio_extensions,
|
| 30 |
+
video_extensions,
|
| 31 |
+
doc_extensions,
|
| 32 |
+
HF_REPO_ID,
|
| 33 |
+
SHORTENER_JSON_FILE,
|
| 34 |
+
)
|
| 35 |
+
from typing import Any, Dict, List, Tuple, Union, Optional
|
| 36 |
+
|
| 37 |
+
# Configure logging
|
| 38 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
| 39 |
+
logger = logging.getLogger(__name__)
|
| 40 |
|
| 41 |
# see storage.md for detailed information about the storage module and its functions.
|
| 42 |
|
|
|
|
| 50 |
images_links = []
|
| 51 |
audio_links = []
|
| 52 |
video_links = []
|
| 53 |
+
doc_links = []
|
| 54 |
for f in valid_files:
|
| 55 |
filename = os.path.basename(f)
|
| 56 |
ext = os.path.splitext(filename)[1].lower()
|
|
|
|
| 63 |
audio_links.append(f"{base_url_external}/{filename}")
|
| 64 |
elif ext in video_extensions:
|
| 65 |
video_links.append(f"{base_url_external}/{filename}")
|
| 66 |
+
elif ext in doc_extensions:
|
| 67 |
+
doc_links.append(f"{base_url_external}/{filename}")
|
| 68 |
if model_link and len(images_links) == 2:
|
| 69 |
# Construct a permalink to the viewer project with query parameters.
|
| 70 |
permalink_viewer_url = f"https://{permalink_viewer_url}/"
|
|
|
|
| 124 |
Otherwise:
|
| 125 |
list: A list of tuples (response, permalink) for each file.
|
| 126 |
"""
|
| 127 |
+
logger.info(f"Starting batch upload to repository: {repo_id}")
|
| 128 |
+
|
| 129 |
# Log in using the HF API token.
|
| 130 |
+
try:
|
| 131 |
+
login(token=HF_API_TOKEN)
|
| 132 |
+
except Exception as e:
|
| 133 |
+
logger.error(f"Authentication failed: {e}")
|
| 134 |
+
return {"response": "Authentication failed", "permalink": None, "short_permalink": None} if create_permalink else []
|
| 135 |
|
| 136 |
valid_files = []
|
| 137 |
permalink_short = None
|
|
|
|
| 167 |
|
| 168 |
# Batch upload all files in the temporary folder.
|
| 169 |
# Files will be uploaded under the folder (path_in_repo) given by folder_name.
|
| 170 |
+
try:
|
| 171 |
+
response = upload_folder(
|
| 172 |
+
folder_path=temp_dir,
|
| 173 |
+
repo_id=repo_id,
|
| 174 |
+
repo_type=repo_type,
|
| 175 |
+
path_in_repo=folder_name,
|
| 176 |
+
commit_message="Batch upload files",
|
| 177 |
+
)
|
| 178 |
+
except Exception as e:
|
| 179 |
+
logger.error(f"Batch upload failed: {e}")
|
| 180 |
+
return {"response": f"Upload failed: {e}", "permalink": None, "short_permalink": None} if create_permalink else []
|
| 181 |
|
| 182 |
# Construct external URLs for each uploaded file.
|
| 183 |
base_url_external = f"https://huggingface.co/datasets/{repo_id}/resolve/main/{folder_name}"
|
|
|
|
| 200 |
if status in ["created_short", "success_retrieved_short", "exists_match"]:
|
| 201 |
permalink_short = f"https://{permalink_viewer_url}/?sid={short_id}"
|
| 202 |
else: # Shortening failed or conflict not resolved to a usable short_id
|
| 203 |
+
permalink_short = None
|
| 204 |
+
logger.warning(f"URL shortening status: {status} for {permalink}")
|
| 205 |
|
| 206 |
return {
|
| 207 |
"response": response,
|
|
|
|
| 218 |
# Otherwise, return individual tuples for each file.
|
| 219 |
return [(response, link) for link in individual_links]
|
| 220 |
|
| 221 |
+
|
| 222 |
+
def _get_files_from_repo(repo_id, file_name, repo_type="dataset"):
|
| 223 |
+
"""Downloads a file from the repo. File must be in upload_file_types."""
|
| 224 |
+
filename = os.path.basename(file_name)
|
| 225 |
+
ext = os.path.splitext(file_name)[1].lower()
|
| 226 |
+
if ext not in upload_file_types:
|
| 227 |
+
logger.error(f"File {filename} with extension {ext} is not allowed for upload.")
|
| 228 |
+
return None
|
| 229 |
+
try:
|
| 230 |
+
login(token=HF_API_TOKEN)
|
| 231 |
+
file_path = hf_hub_download(
|
| 232 |
+
repo_id=repo_id,
|
| 233 |
+
filename=file_name,
|
| 234 |
+
repo_type=repo_type,
|
| 235 |
+
token=HF_API_TOKEN,
|
| 236 |
+
)
|
| 237 |
+
return file_path or None
|
| 238 |
+
except RepositoryNotFoundError:
|
| 239 |
+
logger.warning(f"Repository {repo_id} not found.")
|
| 240 |
+
return None
|
| 241 |
+
except EntryNotFoundError:
|
| 242 |
+
logger.warning(f"file {file_name} not found in {repo_id}.")
|
| 243 |
+
return None
|
| 244 |
+
except Exception as e:
|
| 245 |
+
logger.error(f"Error fetching {file_name} from {repo_id}: {e}")
|
| 246 |
+
return None
|
| 247 |
+
|
| 248 |
def _generate_short_id(length=8):
|
| 249 |
"""Generates a random base64 URL-safe string."""
|
| 250 |
return base64.urlsafe_b64encode(os.urandom(length * 2))[:length].decode('utf-8')
|
|
|
|
| 264 |
os.remove(json_path) # Clean up downloaded file
|
| 265 |
return data
|
| 266 |
except RepositoryNotFoundError:
|
| 267 |
+
logger.warning(f"Repository {repo_id} not found.")
|
| 268 |
return []
|
| 269 |
except EntryNotFoundError:
|
| 270 |
+
logger.warning(f"JSON file {json_file_name} not found in {repo_id}. Initializing with empty list.")
|
| 271 |
return []
|
| 272 |
except json.JSONDecodeError:
|
| 273 |
+
logger.error(f"Error decoding JSON from {json_file_name}. Returning empty list.")
|
| 274 |
return []
|
| 275 |
except Exception as e:
|
| 276 |
+
logger.error(f"An unexpected error occurred while fetching {json_file_name}: {e}")
|
| 277 |
return []
|
| 278 |
|
| 279 |
def _upload_json_to_repo(data, repo_id, json_file_name, repo_type="dataset"):
|
|
|
|
| 299 |
os.remove(tmp_file_path) # Clean up temporary file
|
| 300 |
return True
|
| 301 |
except Exception as e:
|
| 302 |
+
logger.error(f"Failed to upload {json_file_name} to {repo_id}: {e}")
|
| 303 |
if 'tmp_file_path' in locals() and os.path.exists(tmp_file_path):
|
| 304 |
os.remove(tmp_file_path) # Ensure cleanup on error too
|
| 305 |
return False
|
|
|
|
| 392 |
return "error_upload", None
|
| 393 |
|
| 394 |
return "error_unhandled_case", None # Should not be reached
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def _encrypt_private_key(private_key: str, password: str = None) -> str:
|
| 398 |
+
"""Basic XOR encryption for demo purposes only."""
|
| 399 |
+
if not password:
|
| 400 |
+
password = "default_encryption_key"
|
| 401 |
+
encrypted_bytes = [ord(char) ^ ord(password[i % len(password)]) for i, char in enumerate(private_key)]
|
| 402 |
+
return base64.b64encode(bytes(encrypted_bytes)).decode("utf-8")
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def _decrypt_private_key(encrypted_private_key: str, password: str = None) -> str:
|
| 406 |
+
"""Basic XOR decryption for demo purposes only."""
|
| 407 |
+
if not password:
|
| 408 |
+
password = "default_encryption_key"
|
| 409 |
+
encrypted_data = base64.b64decode(encrypted_private_key)
|
| 410 |
+
decrypted_chars = [chr(byte ^ ord(password[i % len(password)])) for i, byte in enumerate(encrypted_data)]
|
| 411 |
+
return "".join(decrypted_chars)
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def store_issuer_keypair(issuer_id: str, public_key: str, private_key: str, repo_id: str = None) -> bool:
|
| 415 |
+
"""Store issuer keypair in HF dataset repo (PRIVATE repo strongly recommended)."""
|
| 416 |
+
if not issuer_id or not public_key or not private_key:
|
| 417 |
+
raise ValueError("issuer_id, public_key, and private_key are required")
|
| 418 |
+
|
| 419 |
+
if not repo_id:
|
| 420 |
+
repo_id = HF_REPO_ID
|
| 421 |
+
|
| 422 |
+
safe_issuer_id = issuer_id.replace("https://", "").replace("http://", "").replace("/", "_").replace(":", "_")
|
| 423 |
+
|
| 424 |
+
encrypted_private_key = _encrypt_private_key(private_key)
|
| 425 |
+
private_key_data = {
|
| 426 |
+
"issuer_id": issuer_id,
|
| 427 |
+
"encrypted_private_key": encrypted_private_key,
|
| 428 |
+
"key_type": "Ed25519VerificationKey2020",
|
| 429 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 430 |
+
"encryption_method": "basic_xor",
|
| 431 |
+
}
|
| 432 |
+
public_key_data = {
|
| 433 |
+
"issuer_id": issuer_id,
|
| 434 |
+
"public_key": public_key,
|
| 435 |
+
"key_type": "Ed25519VerificationKey2020",
|
| 436 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
private_key_path = f"keys/issuers/{safe_issuer_id}/private_key.json"
|
| 440 |
+
public_key_path = f"keys/issuers/{safe_issuer_id}/public_key.json"
|
| 441 |
+
|
| 442 |
+
private_ok = _upload_json_to_repo(private_key_data, repo_id, private_key_path, "dataset")
|
| 443 |
+
public_ok = _upload_json_to_repo(public_key_data, repo_id, public_key_path, "dataset")
|
| 444 |
+
if private_ok and public_ok:
|
| 445 |
+
_update_verification_methods_registry(issuer_id, safe_issuer_id, public_key, repo_id)
|
| 446 |
+
return private_ok and public_ok
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
def get_issuer_keypair(issuer_id: str, repo_id: str = None) -> Tuple[Optional[str], Optional[str]]:
|
| 450 |
+
"""Retrieve stored issuer keypair from HF dataset repo."""
|
| 451 |
+
if not issuer_id:
|
| 452 |
+
raise ValueError("issuer_id is required")
|
| 453 |
+
|
| 454 |
+
if not repo_id:
|
| 455 |
+
repo_id = HF_REPO_ID
|
| 456 |
+
|
| 457 |
+
safe_issuer_id = issuer_id.replace("https://", "").replace("http://", "").replace("/", "_").replace(":", "_")
|
| 458 |
+
|
| 459 |
+
public_key_path = f"keys/issuers/{safe_issuer_id}/public_key.json"
|
| 460 |
+
private_key_path = f"keys/issuers/{safe_issuer_id}/private_key.json"
|
| 461 |
+
public_key_data = _get_json_from_repo(repo_id, public_key_path, "dataset")
|
| 462 |
+
private_key_data = _get_json_from_repo(repo_id, private_key_path, "dataset")
|
| 463 |
+
if not public_key_data or not private_key_data:
|
| 464 |
+
return None, None
|
| 465 |
+
|
| 466 |
+
encrypted_private_key = private_key_data.get("encrypted_private_key")
|
| 467 |
+
if not encrypted_private_key:
|
| 468 |
+
return None, None
|
| 469 |
+
|
| 470 |
+
return public_key_data.get("public_key"), _decrypt_private_key(encrypted_private_key)
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def _update_verification_methods_registry(issuer_id: str, safe_issuer_id: str, public_key: str, repo_id: str):
|
| 474 |
+
try:
|
| 475 |
+
registry_path = "keys/global/verification_methods.json"
|
| 476 |
+
registry_data = _get_json_from_repo(repo_id, registry_path, "dataset")
|
| 477 |
+
|
| 478 |
+
if not registry_data:
|
| 479 |
+
registry_data = {"verification_methods": []}
|
| 480 |
+
|
| 481 |
+
existing_entry = None
|
| 482 |
+
for i, method in enumerate(registry_data.get("verification_methods", [])):
|
| 483 |
+
if method.get("issuer_id") == issuer_id:
|
| 484 |
+
existing_entry = i
|
| 485 |
+
break
|
| 486 |
+
|
| 487 |
+
verification_method = {
|
| 488 |
+
"issuer_id": issuer_id,
|
| 489 |
+
"safe_issuer_id": safe_issuer_id,
|
| 490 |
+
"public_key": public_key,
|
| 491 |
+
"key_type": "Ed25519VerificationKey2020",
|
| 492 |
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
if existing_entry is not None:
|
| 496 |
+
registry_data["verification_methods"][existing_entry] = verification_method
|
| 497 |
+
else:
|
| 498 |
+
registry_data["verification_methods"].append(verification_method)
|
| 499 |
+
|
| 500 |
+
_upload_json_to_repo(registry_data, repo_id, registry_path, "dataset")
|
| 501 |
+
except Exception as e:
|
| 502 |
+
logger.error(f"Error updating verification methods registry: {e}")
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
def get_verification_methods_registry(repo_id: str = None) -> Dict[str, Any]:
|
| 506 |
+
if not repo_id:
|
| 507 |
+
repo_id = HF_REPO_ID
|
| 508 |
+
registry_path = "keys/global/verification_methods.json"
|
| 509 |
+
registry_data = _get_json_from_repo(repo_id, registry_path, "dataset")
|
| 510 |
+
return registry_data if registry_data else {"verification_methods": []}
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
def list_issuer_ids(repo_id: str = None) -> List[str]:
|
| 514 |
+
if not repo_id:
|
| 515 |
+
repo_id = HF_REPO_ID
|
| 516 |
+
registry = get_verification_methods_registry(repo_id)
|
| 517 |
+
return [method["issuer_id"] for method in registry.get("verification_methods", [])]
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def _list_repo_folders(repo_id: str, path_prefix: str, repo_type: str = "dataset") -> List[str]:
|
| 521 |
+
try:
|
| 522 |
+
login(token=HF_API_TOKEN)
|
| 523 |
+
api = HfApi()
|
| 524 |
+
all_files = api.list_repo_files(repo_id=repo_id, repo_type=repo_type, token=HF_API_TOKEN)
|
| 525 |
+
|
| 526 |
+
if path_prefix and not path_prefix.endswith("/"):
|
| 527 |
+
path_prefix = path_prefix + "/"
|
| 528 |
+
|
| 529 |
+
folders = set()
|
| 530 |
+
for file_path in all_files:
|
| 531 |
+
if file_path.startswith(path_prefix):
|
| 532 |
+
relative_path = file_path[len(path_prefix) :]
|
| 533 |
+
if "/" in relative_path:
|
| 534 |
+
folders.add(relative_path.split("/")[0])
|
| 535 |
+
return sorted(list(folders))
|
| 536 |
+
except RepositoryNotFoundError:
|
| 537 |
+
logger.warning(f"Repository {repo_id} not found.")
|
| 538 |
+
return []
|
| 539 |
+
except Exception as e:
|
| 540 |
+
logger.error(f"Error listing folders in {repo_id}/{path_prefix}: {e}")
|
| 541 |
+
return []
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
def _list_repo_files_in_folder(repo_id: str, folder_path: str, repo_type: str = "dataset") -> List[str]:
|
| 545 |
+
try:
|
| 546 |
+
login(token=HF_API_TOKEN)
|
| 547 |
+
api = HfApi()
|
| 548 |
+
all_files = api.list_repo_files(repo_id=repo_id, repo_type=repo_type, token=HF_API_TOKEN)
|
| 549 |
+
|
| 550 |
+
if folder_path and not folder_path.endswith("/"):
|
| 551 |
+
folder_path = folder_path + "/"
|
| 552 |
+
|
| 553 |
+
files: List[str] = []
|
| 554 |
+
for file_path in all_files:
|
| 555 |
+
if file_path.startswith(folder_path):
|
| 556 |
+
relative_path = file_path[len(folder_path) :]
|
| 557 |
+
if "/" not in relative_path and relative_path:
|
| 558 |
+
files.append(relative_path)
|
| 559 |
+
return sorted(files)
|
| 560 |
+
except RepositoryNotFoundError:
|
| 561 |
+
logger.warning(f"Repository {repo_id} not found.")
|
| 562 |
+
return []
|
| 563 |
+
except Exception as e:
|
| 564 |
+
logger.error(f"Error listing files in {repo_id}/{folder_path}: {e}")
|
| 565 |
+
return []
|