Beauti mode
Browse files- HF_DEPLOYMENT_COMPLETE.md +170 -0
- algorithms/backend/api/routes/optimization_routes.py +3 -2
- algorithms/backend/api/schemas/request_schemas.py +6 -6
- algorithms/backend/core/config/settings.py +50 -5
- algorithms/backend/core/geometry/__init__.py +9 -0
- algorithms/backend/core/geometry/orthogonal_slicer.py +263 -0
- algorithms/backend/core/geometry/shape_quality.py +220 -0
- algorithms/backend/core/optimization/grid_optimizer.py +79 -29
- algorithms/backend/core/optimization/subdivision_solver.py +47 -38
- algorithms/backend/pipeline/land_redistribution.py +163 -29
- algorithms/backend/test_aesthetic.py +162 -0
- algorithms/backend/test_real_dxf.py +89 -0
- algorithms/frontend/app.py +8 -8
- docs/Beauti_mode.md +229 -0
HF_DEPLOYMENT_COMPLETE.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ✅ Hugging Face Spaces Deployment Complete!
|
| 2 |
+
|
| 3 |
+
Your backend API has been successfully deployed to Hugging Face Spaces.
|
| 4 |
+
|
| 5 |
+
## 🌐 Your Deployed API
|
| 6 |
+
|
| 7 |
+
**URL**: https://cuong2004-remb.hf.space
|
| 8 |
+
|
| 9 |
+
**API Documentation**: https://cuong2004-remb.hf.space/docs
|
| 10 |
+
|
| 11 |
+
**Health Check**: https://cuong2004-remb.hf.space/health
|
| 12 |
+
|
| 13 |
+
## 📝 What Was Fixed
|
| 14 |
+
|
| 15 |
+
### 1. README.md with Proper Metadata
|
| 16 |
+
Added Hugging Face Spaces YAML frontmatter:
|
| 17 |
+
```yaml
|
| 18 |
+
---
|
| 19 |
+
title: REMB - Land Redistribution API
|
| 20 |
+
emoji: 🏘️
|
| 21 |
+
colorFrom: blue
|
| 22 |
+
colorTo: green
|
| 23 |
+
sdk: docker
|
| 24 |
+
app_port: 7860
|
| 25 |
+
pinned: false
|
| 26 |
+
---
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
### 2. Dockerfile for Root Directory
|
| 30 |
+
Created Dockerfile that:
|
| 31 |
+
- Uses multi-stage build for optimization
|
| 32 |
+
- References `algorithms/backend/` code
|
| 33 |
+
- Exposes port 7860 (HF Spaces standard)
|
| 34 |
+
- Runs as non-root user
|
| 35 |
+
|
| 36 |
+
### 3. .dockerignore Optimization
|
| 37 |
+
Configured to exclude:
|
| 38 |
+
- Source code outside `algorithms/backend/`
|
| 39 |
+
- Test files and notebooks
|
| 40 |
+
- Virtual environments
|
| 41 |
+
- Documentation
|
| 42 |
+
|
| 43 |
+
## 🔄 Build Status
|
| 44 |
+
|
| 45 |
+
Hugging Face is now building your Docker container. This typically takes **5-10 minutes**.
|
| 46 |
+
|
| 47 |
+
**Monitor build progress**:
|
| 48 |
+
1. Visit https://huggingface.co/spaces/Cuong2004/REMB
|
| 49 |
+
2. Click on "App" or "Logs" tab
|
| 50 |
+
3. Watch the build logs
|
| 51 |
+
|
| 52 |
+
## 🧪 Testing Your API
|
| 53 |
+
|
| 54 |
+
Once the build completes, test your API:
|
| 55 |
+
|
| 56 |
+
### 1. Health Check
|
| 57 |
+
```bash
|
| 58 |
+
curl https://cuong2004-remb.hf.space/health
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
Expected response:
|
| 62 |
+
```json
|
| 63 |
+
{
|
| 64 |
+
"status": "healthy",
|
| 65 |
+
"version": "2.0.0"
|
| 66 |
+
}
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
### 2. Run Optimization
|
| 70 |
+
```bash
|
| 71 |
+
curl -X POST https://cuong2004-remb.hf.space/api/optimize \
|
| 72 |
+
-H "Content-Type: application/json" \
|
| 73 |
+
-d '{
|
| 74 |
+
"config": {
|
| 75 |
+
"spacing_min": 20,
|
| 76 |
+
"spacing_max": 30,
|
| 77 |
+
"population_size": 20,
|
| 78 |
+
"generations": 50
|
| 79 |
+
},
|
| 80 |
+
"land_plots": [{
|
| 81 |
+
"type": "Polygon",
|
| 82 |
+
"coordinates": [[[0,0],[100,0],[100,100],[0,100],[0,0]]]
|
| 83 |
+
}]
|
| 84 |
+
}'
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
### 3. View API Documentation
|
| 88 |
+
Open in browser: https://cuong2004-remb.hf.space/docs
|
| 89 |
+
|
| 90 |
+
## 🎨 Next Step: Deploy Frontend
|
| 91 |
+
|
| 92 |
+
Now deploy your Streamlit frontend to work with this backend:
|
| 93 |
+
|
| 94 |
+
### 1. Update Frontend API URL
|
| 95 |
+
|
| 96 |
+
In `/Volumes/WorkSpace/Project/REMB/algorithms/frontend`, create `.env`:
|
| 97 |
+
```bash
|
| 98 |
+
API_URL=https://cuong2004-remb.hf.space
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
### 2. Deploy to Streamlit Cloud
|
| 102 |
+
|
| 103 |
+
```bash
|
| 104 |
+
cd /Volumes/WorkSpace/Project/REMB/algorithms/frontend
|
| 105 |
+
|
| 106 |
+
# Push to GitHub
|
| 107 |
+
git init
|
| 108 |
+
git remote add origin https://github.com/<YOUR_USERNAME>/remb-frontend.git
|
| 109 |
+
git add .
|
| 110 |
+
git commit -m "Initial commit"
|
| 111 |
+
git push -u origin main
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
### 3. Configure Streamlit Cloud
|
| 115 |
+
|
| 116 |
+
1. Go to https://streamlit.io/cloud
|
| 117 |
+
2. Create new app
|
| 118 |
+
3. Select your repository
|
| 119 |
+
4. Set main file: `app.py`
|
| 120 |
+
5. Add secret in settings:
|
| 121 |
+
```toml
|
| 122 |
+
API_URL = "https://cuong2004-remb.hf.space"
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
## 📊 Architecture
|
| 126 |
+
|
| 127 |
+
```
|
| 128 |
+
┌──────────────────┐ ┌─────────────────────┐
|
| 129 |
+
│ Streamlit Cloud │ HTTP │ Hugging Face │
|
| 130 |
+
│ (Frontend) │───────▶ │ Spaces │
|
| 131 |
+
│ │ │ (Backend API) │
|
| 132 |
+
│ To be deployed │ │ ✅ DEPLOYED │
|
| 133 |
+
└──────────────────┘ └─────────────────────┘
|
| 134 |
+
│
|
| 135 |
+
https://cuong2004-remb.hf.space
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
## 🐛 Troubleshooting
|
| 139 |
+
|
| 140 |
+
### If build fails:
|
| 141 |
+
1. Check build logs on HF Spaces
|
| 142 |
+
2. Verify Dockerfile syntax
|
| 143 |
+
3. Ensure all dependencies are in `algorithms/backend/requirements.txt`
|
| 144 |
+
|
| 145 |
+
### If API returns errors:
|
| 146 |
+
1. Check application logs in HF Spaces
|
| 147 |
+
2. Verify the backend code works locally
|
| 148 |
+
3. Test with simple requests first
|
| 149 |
+
|
| 150 |
+
### Common Issues:
|
| 151 |
+
|
| 152 |
+
**Port issues**: HF Spaces requires port 7860 ✅ (configured)
|
| 153 |
+
|
| 154 |
+
**Missing dependencies**: All requirements in `requirements.txt` ✅ (configured)
|
| 155 |
+
|
| 156 |
+
**CORS**: Already configured to allow all origins ✅
|
| 157 |
+
|
| 158 |
+
## 📁 Files Modified
|
| 159 |
+
|
| 160 |
+
- ✅ `/README.md` - Added HF Spaces metadata
|
| 161 |
+
- ✅ `/Dockerfile` - Created for root deployment
|
| 162 |
+
- ✅ `/.dockerignore` - Optimized for build
|
| 163 |
+
|
| 164 |
+
## 🎉 Congratulations!
|
| 165 |
+
|
| 166 |
+
Your backend API is now deployed and will be publicly accessible at:
|
| 167 |
+
|
| 168 |
+
**https://cuong2004-remb.hf.space**
|
| 169 |
+
|
| 170 |
+
Wait for the build to complete, then test the API!
|
algorithms/backend/api/routes/optimization_routes.py
CHANGED
|
@@ -40,10 +40,11 @@ async def optimize_full(request: OptimizationRequest):
|
|
| 40 |
|
| 41 |
# Create pipeline
|
| 42 |
config = request.config.dict()
|
|
|
|
| 43 |
pipeline = LandRedistributionPipeline(land_polygons, config)
|
| 44 |
|
| 45 |
-
# Run optimization
|
| 46 |
-
result = pipeline.run_full_pipeline()
|
| 47 |
|
| 48 |
# Build stage results
|
| 49 |
stages = []
|
|
|
|
| 40 |
|
| 41 |
# Create pipeline
|
| 42 |
config = request.config.dict()
|
| 43 |
+
logger.info(f"API Request Config: Spacing=[{config.get('spacing_min')}, {config.get('spacing_max')}], RoadWidth={config.get('road_width')}")
|
| 44 |
pipeline = LandRedistributionPipeline(land_polygons, config)
|
| 45 |
|
| 46 |
+
# Run optimization with Grid layout method (orthogonal alignment for better aesthetics)
|
| 47 |
+
result = pipeline.run_full_pipeline(layout_method='grid')
|
| 48 |
|
| 49 |
# Build stage results
|
| 50 |
stages = []
|
algorithms/backend/api/schemas/request_schemas.py
CHANGED
|
@@ -8,19 +8,19 @@ class AlgorithmConfig(BaseModel):
|
|
| 8 |
"""Configuration parameters for the land redistribution algorithm."""
|
| 9 |
|
| 10 |
# Stage 1: Grid optimization parameters
|
| 11 |
-
spacing_min: float = Field(default=20.0, ge=10.0, le=
|
| 12 |
-
spacing_max: float = Field(default=30.0, ge=10.0, le=
|
| 13 |
angle_min: float = Field(default=0.0, ge=0.0, le=90.0, description="Minimum grid angle in degrees")
|
| 14 |
angle_max: float = Field(default=90.0, ge=0.0, le=90.0, description="Maximum grid angle in degrees")
|
| 15 |
|
| 16 |
# Stage 2: Subdivision parameters (Industrial lots)
|
| 17 |
-
min_lot_width: float = Field(default=20.0, ge=10.0, le=
|
| 18 |
-
max_lot_width: float = Field(default=80.0, ge=40.0, le=
|
| 19 |
target_lot_width: float = Field(default=40.0, ge=20.0, le=100.0, description="Target lot width in meters")
|
| 20 |
|
| 21 |
# Infrastructure parameters
|
| 22 |
-
road_width: float = Field(default=6.0, ge=3.0, le=
|
| 23 |
-
block_depth: float = Field(default=50.0, ge=30.0, le=
|
| 24 |
|
| 25 |
# Optimization parameters
|
| 26 |
population_size: int = Field(default=50, ge=20, le=200, description="NSGA-II population size")
|
|
|
|
| 8 |
"""Configuration parameters for the land redistribution algorithm."""
|
| 9 |
|
| 10 |
# Stage 1: Grid optimization parameters
|
| 11 |
+
spacing_min: float = Field(default=20.0, ge=10.0, le=200.0, description="Minimum grid spacing in meters")
|
| 12 |
+
spacing_max: float = Field(default=30.0, ge=10.0, le=200.0, description="Maximum grid spacing in meters")
|
| 13 |
angle_min: float = Field(default=0.0, ge=0.0, le=90.0, description="Minimum grid angle in degrees")
|
| 14 |
angle_max: float = Field(default=90.0, ge=0.0, le=90.0, description="Maximum grid angle in degrees")
|
| 15 |
|
| 16 |
# Stage 2: Subdivision parameters (Industrial lots)
|
| 17 |
+
min_lot_width: float = Field(default=20.0, ge=10.0, le=100.0, description="Minimum lot width in meters")
|
| 18 |
+
max_lot_width: float = Field(default=80.0, ge=40.0, le=200.0, description="Maximum lot width in meters")
|
| 19 |
target_lot_width: float = Field(default=40.0, ge=20.0, le=100.0, description="Target lot width in meters")
|
| 20 |
|
| 21 |
# Infrastructure parameters
|
| 22 |
+
road_width: float = Field(default=6.0, ge=3.0, le=30.0, description="Road width in meters")
|
| 23 |
+
block_depth: float = Field(default=50.0, ge=30.0, le=200.0, description="Block depth in meters")
|
| 24 |
|
| 25 |
# Optimization parameters
|
| 26 |
population_size: int = Field(default=50, ge=20, le=200, description="NSGA-II population size")
|
algorithms/backend/core/config/settings.py
CHANGED
|
@@ -14,8 +14,8 @@ class RoadSettings:
|
|
| 14 |
"""Road and transportation infrastructure settings (TCVN standards)."""
|
| 15 |
|
| 16 |
# Road widths (meters)
|
| 17 |
-
main_width: float =
|
| 18 |
-
internal_width: float =
|
| 19 |
sidewalk_width: float = 4.0 # Sidewalk each side (includes utility trench)
|
| 20 |
turning_radius: float = 15.0 # Corner chamfer radius for intersections
|
| 21 |
|
|
@@ -25,8 +25,8 @@ class SubdivisionSettings:
|
|
| 25 |
"""Block and lot subdivision settings."""
|
| 26 |
|
| 27 |
# Land allocation
|
| 28 |
-
service_area_ratio: float = 0.10 # 10% for infrastructure
|
| 29 |
-
min_block_area: float =
|
| 30 |
|
| 31 |
# Lot dimensions (industrial)
|
| 32 |
min_lot_width: float = 20.0 # Minimum lot frontage (m)
|
|
@@ -71,7 +71,8 @@ class OptimizationSettings:
|
|
| 71 |
eta: float = 20.0 # Distribution index for SBX crossover
|
| 72 |
|
| 73 |
# Gene bounds
|
| 74 |
-
|
|
|
|
| 75 |
angle_bounds: Tuple[float, float] = (0.0, 90.0)
|
| 76 |
|
| 77 |
# Block quality thresholds
|
|
@@ -79,6 +80,28 @@ class OptimizationSettings:
|
|
| 79 |
fragmented_block_ratio: float = 0.1 # Below this = too small
|
| 80 |
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
@dataclass
|
| 83 |
class AlgorithmSettings:
|
| 84 |
"""Complete algorithm configuration."""
|
|
@@ -87,6 +110,7 @@ class AlgorithmSettings:
|
|
| 87 |
subdivision: SubdivisionSettings = field(default_factory=SubdivisionSettings)
|
| 88 |
infrastructure: InfrastructureSettings = field(default_factory=InfrastructureSettings)
|
| 89 |
optimization: OptimizationSettings = field(default_factory=OptimizationSettings)
|
|
|
|
| 90 |
|
| 91 |
# Random seed for reproducibility
|
| 92 |
random_seed: int = 42
|
|
@@ -108,7 +132,21 @@ class AlgorithmSettings:
|
|
| 108 |
optimization=OptimizationSettings(
|
| 109 |
population_size=config.get('population_size', 30),
|
| 110 |
generations=config.get('generations', 15),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
)
|
| 113 |
|
| 114 |
return settings
|
|
@@ -132,3 +170,10 @@ SETBACK_DISTANCE = DEFAULT_SETTINGS.subdivision.setback_distance
|
|
| 132 |
FIRE_SAFETY_GAP = DEFAULT_SETTINGS.subdivision.fire_safety_gap
|
| 133 |
SOLVER_TIME_LIMIT = DEFAULT_SETTINGS.subdivision.solver_time_limit
|
| 134 |
TRANSFORMER_RADIUS = DEFAULT_SETTINGS.infrastructure.transformer_radius
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"""Road and transportation infrastructure settings (TCVN standards)."""
|
| 15 |
|
| 16 |
# Road widths (meters)
|
| 17 |
+
main_width: float = 20.0 # Main road
|
| 18 |
+
internal_width: float = 10.0 # Internal road (reduced from 15.0 to allow better efficiency)
|
| 19 |
sidewalk_width: float = 4.0 # Sidewalk each side (includes utility trench)
|
| 20 |
turning_radius: float = 15.0 # Corner chamfer radius for intersections
|
| 21 |
|
|
|
|
| 25 |
"""Block and lot subdivision settings."""
|
| 26 |
|
| 27 |
# Land allocation
|
| 28 |
+
service_area_ratio: float = 0.10 # 10% for infrastructure
|
| 29 |
+
min_block_area: float = 400.0 # Minimum block area (reduced from 5000.0 to 400.0)
|
| 30 |
|
| 31 |
# Lot dimensions (industrial)
|
| 32 |
min_lot_width: float = 20.0 # Minimum lot frontage (m)
|
|
|
|
| 71 |
eta: float = 20.0 # Distribution index for SBX crossover
|
| 72 |
|
| 73 |
# Gene bounds
|
| 74 |
+
# Gene bounds - Increased to create larger, more usable blocks
|
| 75 |
+
spacing_bounds: Tuple[float, float] = (50.0, 150.0)
|
| 76 |
angle_bounds: Tuple[float, float] = (0.0, 90.0)
|
| 77 |
|
| 78 |
# Block quality thresholds
|
|
|
|
| 80 |
fragmented_block_ratio: float = 0.1 # Below this = too small
|
| 81 |
|
| 82 |
|
| 83 |
+
@dataclass(frozen=True)
|
| 84 |
+
class AestheticSettings:
|
| 85 |
+
"""Shape quality thresholds for aesthetic optimization (from Beauti_mode)."""
|
| 86 |
+
|
| 87 |
+
# Rectangularity: area / OBB area (1.0 = perfect rectangle)
|
| 88 |
+
# Relaxed to 0.65 to accept trapezoids from Voronoi slicing
|
| 89 |
+
min_rectangularity: float = 0.65
|
| 90 |
+
|
| 91 |
+
# Aspect ratio: length / width (lower = more square)
|
| 92 |
+
max_aspect_ratio: float = 4.0
|
| 93 |
+
|
| 94 |
+
# Minimum lot area to avoid tiny fragments (m²)
|
| 95 |
+
# Relaxed from 1000.0 to 250.0 to accept standard industrial/residential lots
|
| 96 |
+
min_lot_area: float = 250.0
|
| 97 |
+
|
| 98 |
+
# OR-Tools deviation penalty weight (higher = more uniform lots)
|
| 99 |
+
deviation_penalty_weight: float = 50.0
|
| 100 |
+
|
| 101 |
+
# Enable leftover management (convert poor lots to green space)
|
| 102 |
+
enable_leftover_management: bool = True
|
| 103 |
+
|
| 104 |
+
|
| 105 |
@dataclass
|
| 106 |
class AlgorithmSettings:
|
| 107 |
"""Complete algorithm configuration."""
|
|
|
|
| 110 |
subdivision: SubdivisionSettings = field(default_factory=SubdivisionSettings)
|
| 111 |
infrastructure: InfrastructureSettings = field(default_factory=InfrastructureSettings)
|
| 112 |
optimization: OptimizationSettings = field(default_factory=OptimizationSettings)
|
| 113 |
+
aesthetic: AestheticSettings = field(default_factory=AestheticSettings)
|
| 114 |
|
| 115 |
# Random seed for reproducibility
|
| 116 |
random_seed: int = 42
|
|
|
|
| 132 |
optimization=OptimizationSettings(
|
| 133 |
population_size=config.get('population_size', 30),
|
| 134 |
generations=config.get('generations', 15),
|
| 135 |
+
spacing_bounds=(
|
| 136 |
+
config.get('spacing_min', 50.0),
|
| 137 |
+
config.get('spacing_max', 150.0)
|
| 138 |
+
),
|
| 139 |
+
angle_bounds=(
|
| 140 |
+
config.get('angle_min', 0.0),
|
| 141 |
+
config.get('angle_max', 90.0)
|
| 142 |
+
),
|
| 143 |
),
|
| 144 |
+
road=RoadSettings(
|
| 145 |
+
main_width=DEFAULT_SETTINGS.road.main_width,
|
| 146 |
+
internal_width=config.get('road_width', DEFAULT_SETTINGS.road.internal_width),
|
| 147 |
+
sidewalk_width=DEFAULT_SETTINGS.road.sidewalk_width,
|
| 148 |
+
turning_radius=DEFAULT_SETTINGS.road.turning_radius
|
| 149 |
+
)
|
| 150 |
)
|
| 151 |
|
| 152 |
return settings
|
|
|
|
| 170 |
FIRE_SAFETY_GAP = DEFAULT_SETTINGS.subdivision.fire_safety_gap
|
| 171 |
SOLVER_TIME_LIMIT = DEFAULT_SETTINGS.subdivision.solver_time_limit
|
| 172 |
TRANSFORMER_RADIUS = DEFAULT_SETTINGS.infrastructure.transformer_radius
|
| 173 |
+
|
| 174 |
+
# Aesthetic thresholds (from Beauti_mode)
|
| 175 |
+
MIN_RECTANGULARITY = DEFAULT_SETTINGS.aesthetic.min_rectangularity
|
| 176 |
+
MAX_ASPECT_RATIO = DEFAULT_SETTINGS.aesthetic.max_aspect_ratio
|
| 177 |
+
MIN_LOT_AREA = DEFAULT_SETTINGS.aesthetic.min_lot_area
|
| 178 |
+
DEVIATION_PENALTY_WEIGHT = DEFAULT_SETTINGS.aesthetic.deviation_penalty_weight
|
| 179 |
+
ENABLE_LEFTOVER_MANAGEMENT = DEFAULT_SETTINGS.aesthetic.enable_leftover_management
|
algorithms/backend/core/geometry/__init__.py
CHANGED
|
@@ -8,3 +8,12 @@ from core.geometry.voronoi import (
|
|
| 8 |
create_voronoi_diagram,
|
| 9 |
extract_voronoi_edges,
|
| 10 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
create_voronoi_diagram,
|
| 9 |
extract_voronoi_edges,
|
| 10 |
)
|
| 11 |
+
from core.geometry.shape_quality import (
|
| 12 |
+
analyze_shape_quality,
|
| 13 |
+
get_dominant_edge_vector,
|
| 14 |
+
classify_lot_type,
|
| 15 |
+
)
|
| 16 |
+
from core.geometry.orthogonal_slicer import (
|
| 17 |
+
orthogonal_slice,
|
| 18 |
+
subdivide_with_uniform_widths,
|
| 19 |
+
)
|
algorithms/backend/core/geometry/orthogonal_slicer.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Orthogonal slicing for regular lot subdivision.
|
| 3 |
+
|
| 4 |
+
Implements orthogonal alignment from Beauti_mode.md:
|
| 5 |
+
- Cuts perpendicular to dominant edge (usually frontage)
|
| 6 |
+
- Creates parallel, regular lot shapes
|
| 7 |
+
- Uses coordinate rotation for clean geometry
|
| 8 |
+
|
| 9 |
+
Forces 90-degree cutting angles for professional-looking layouts.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
from shapely.geometry import Polygon, LineString, box
|
| 14 |
+
from shapely.affinity import rotate, translate
|
| 15 |
+
from shapely.ops import split
|
| 16 |
+
from typing import List, Optional, Tuple
|
| 17 |
+
|
| 18 |
+
from core.geometry.shape_quality import (
|
| 19 |
+
get_dominant_edge_vector,
|
| 20 |
+
get_perpendicular_vector,
|
| 21 |
+
get_obb_dimensions
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def orthogonal_slice(
|
| 26 |
+
block: Polygon,
|
| 27 |
+
lot_widths: List[float],
|
| 28 |
+
buffer_distance: float = 0.1
|
| 29 |
+
) -> List[Polygon]:
|
| 30 |
+
"""
|
| 31 |
+
Slice block into lots perpendicular to its dominant edge.
|
| 32 |
+
|
| 33 |
+
This creates regular, parallel lots aligned to the block's
|
| 34 |
+
natural orientation (typically road frontage).
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
block: Block polygon to subdivide
|
| 38 |
+
lot_widths: List of widths for each lot
|
| 39 |
+
buffer_distance: Small buffer to ensure clean cuts
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
List of lot polygons
|
| 43 |
+
"""
|
| 44 |
+
if block.is_empty or not block.is_valid:
|
| 45 |
+
return []
|
| 46 |
+
|
| 47 |
+
if not lot_widths:
|
| 48 |
+
return [block]
|
| 49 |
+
|
| 50 |
+
# Get dominant direction vector
|
| 51 |
+
direction_vec = get_dominant_edge_vector(block)
|
| 52 |
+
if direction_vec is None:
|
| 53 |
+
# Fallback to axis-aligned slicing
|
| 54 |
+
return _axis_aligned_slice(block, lot_widths)
|
| 55 |
+
|
| 56 |
+
# Get perpendicular vector for cutting
|
| 57 |
+
perp_vec = get_perpendicular_vector(direction_vec)
|
| 58 |
+
|
| 59 |
+
# Calculate rotation angle to align with X-axis
|
| 60 |
+
angle_rad = np.arctan2(direction_vec[1], direction_vec[0])
|
| 61 |
+
angle_deg = np.degrees(angle_rad)
|
| 62 |
+
|
| 63 |
+
# Rotate block to align dominant edge with X-axis
|
| 64 |
+
center = block.centroid
|
| 65 |
+
rotated_block = rotate(block, -angle_deg, origin=center)
|
| 66 |
+
|
| 67 |
+
# Perform axis-aligned slicing on rotated block
|
| 68 |
+
rotated_lots = _axis_aligned_slice(rotated_block, lot_widths)
|
| 69 |
+
|
| 70 |
+
# Rotate lots back to original orientation
|
| 71 |
+
lots = [rotate(lot, angle_deg, origin=center) for lot in rotated_lots]
|
| 72 |
+
|
| 73 |
+
# Clip to original block boundary (handles edge cases)
|
| 74 |
+
clipped_lots = []
|
| 75 |
+
for lot in lots:
|
| 76 |
+
clipped = lot.intersection(block)
|
| 77 |
+
if not clipped.is_empty and clipped.geom_type == 'Polygon':
|
| 78 |
+
clipped_lots.append(clipped)
|
| 79 |
+
|
| 80 |
+
return clipped_lots
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _axis_aligned_slice(
|
| 84 |
+
block: Polygon,
|
| 85 |
+
lot_widths: List[float]
|
| 86 |
+
) -> List[Polygon]:
|
| 87 |
+
"""
|
| 88 |
+
Slice block along X-axis (assumes block is axis-aligned).
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
block: Block polygon to subdivide
|
| 92 |
+
lot_widths: List of widths for each lot
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
List of lot polygons
|
| 96 |
+
"""
|
| 97 |
+
minx, miny, maxx, maxy = block.bounds
|
| 98 |
+
total_width = maxx - minx
|
| 99 |
+
block_height = maxy - miny
|
| 100 |
+
|
| 101 |
+
# Scale widths to fit block if necessary
|
| 102 |
+
sum_widths = sum(lot_widths)
|
| 103 |
+
if sum_widths > 0:
|
| 104 |
+
scale_factor = total_width / sum_widths
|
| 105 |
+
scaled_widths = [w * scale_factor for w in lot_widths]
|
| 106 |
+
else:
|
| 107 |
+
return [block]
|
| 108 |
+
|
| 109 |
+
lots = []
|
| 110 |
+
current_x = minx
|
| 111 |
+
|
| 112 |
+
for width in scaled_widths:
|
| 113 |
+
# Create rectangular lot
|
| 114 |
+
lot_poly = box(current_x, miny, current_x + width, maxy)
|
| 115 |
+
|
| 116 |
+
# Clip to block boundary
|
| 117 |
+
clipped = lot_poly.intersection(block)
|
| 118 |
+
if not clipped.is_empty and clipped.geom_type == 'Polygon':
|
| 119 |
+
lots.append(clipped)
|
| 120 |
+
|
| 121 |
+
current_x += width
|
| 122 |
+
|
| 123 |
+
return lots
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def slice_along_direction(
|
| 127 |
+
block: Polygon,
|
| 128 |
+
direction_vec: np.ndarray,
|
| 129 |
+
num_slices: int,
|
| 130 |
+
target_width: Optional[float] = None
|
| 131 |
+
) -> List[Polygon]:
|
| 132 |
+
"""
|
| 133 |
+
Slice block along a specific direction vector.
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
block: Block polygon to subdivide
|
| 137 |
+
direction_vec: Unit vector for slice direction
|
| 138 |
+
num_slices: Number of slices to create
|
| 139 |
+
target_width: Optional target width (overrides num_slices)
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
List of lot polygons
|
| 143 |
+
"""
|
| 144 |
+
if block.is_empty or not block.is_valid:
|
| 145 |
+
return []
|
| 146 |
+
|
| 147 |
+
if num_slices <= 0:
|
| 148 |
+
return [block]
|
| 149 |
+
|
| 150 |
+
# Get OBB dimensions
|
| 151 |
+
width, length, _ = get_obb_dimensions(block)
|
| 152 |
+
|
| 153 |
+
# Calculate widths
|
| 154 |
+
if target_width and target_width > 0:
|
| 155 |
+
num_slices = max(1, int(length / target_width))
|
| 156 |
+
|
| 157 |
+
slice_width = length / num_slices if num_slices > 0 else length
|
| 158 |
+
lot_widths = [slice_width] * num_slices
|
| 159 |
+
|
| 160 |
+
return orthogonal_slice(block, lot_widths)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def create_cutting_lines(
|
| 164 |
+
block: Polygon,
|
| 165 |
+
num_cuts: int,
|
| 166 |
+
direction_vec: Optional[np.ndarray] = None
|
| 167 |
+
) -> List[LineString]:
|
| 168 |
+
"""
|
| 169 |
+
Create cutting lines for visualization or debugging.
|
| 170 |
+
|
| 171 |
+
Args:
|
| 172 |
+
block: Block polygon
|
| 173 |
+
num_cuts: Number of cutting lines
|
| 174 |
+
direction_vec: Optional direction vector (uses dominant if None)
|
| 175 |
+
|
| 176 |
+
Returns:
|
| 177 |
+
List of LineString cutting lines
|
| 178 |
+
"""
|
| 179 |
+
if block.is_empty or not block.is_valid:
|
| 180 |
+
return []
|
| 181 |
+
|
| 182 |
+
if direction_vec is None:
|
| 183 |
+
direction_vec = get_dominant_edge_vector(block)
|
| 184 |
+
if direction_vec is None:
|
| 185 |
+
return []
|
| 186 |
+
|
| 187 |
+
perp_vec = get_perpendicular_vector(direction_vec)
|
| 188 |
+
|
| 189 |
+
# Get block bounds and diagonal
|
| 190 |
+
minx, miny, maxx, maxy = block.bounds
|
| 191 |
+
diagonal = np.hypot(maxx - minx, maxy - miny)
|
| 192 |
+
|
| 193 |
+
# Get OBB dimensions for spacing
|
| 194 |
+
width, length, _ = get_obb_dimensions(block)
|
| 195 |
+
spacing = length / (num_cuts + 1) if num_cuts > 0 else length
|
| 196 |
+
|
| 197 |
+
center = np.array([block.centroid.x, block.centroid.y])
|
| 198 |
+
|
| 199 |
+
cutting_lines = []
|
| 200 |
+
|
| 201 |
+
for i in range(1, num_cuts + 1):
|
| 202 |
+
# Calculate offset from center
|
| 203 |
+
offset = (i - (num_cuts + 1) / 2) * spacing
|
| 204 |
+
|
| 205 |
+
# Line center point
|
| 206 |
+
line_center = center + direction_vec * offset
|
| 207 |
+
|
| 208 |
+
# Create line extending perpendicular to direction
|
| 209 |
+
p1 = line_center - perp_vec * diagonal
|
| 210 |
+
p2 = line_center + perp_vec * diagonal
|
| 211 |
+
|
| 212 |
+
line = LineString([p1, p2])
|
| 213 |
+
cutting_lines.append(line)
|
| 214 |
+
|
| 215 |
+
return cutting_lines
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def subdivide_with_uniform_widths(
|
| 219 |
+
block: Polygon,
|
| 220 |
+
target_width: float = 40.0,
|
| 221 |
+
min_width: float = 20.0,
|
| 222 |
+
max_width: float = 80.0
|
| 223 |
+
) -> Tuple[List[Polygon], List[float]]:
|
| 224 |
+
"""
|
| 225 |
+
Subdivide block with uniform lot widths.
|
| 226 |
+
|
| 227 |
+
Calculates optimal number of lots to match target width
|
| 228 |
+
while respecting min/max constraints.
|
| 229 |
+
|
| 230 |
+
Args:
|
| 231 |
+
block: Block polygon to subdivide
|
| 232 |
+
target_width: Target lot width (m)
|
| 233 |
+
min_width: Minimum acceptable width (m)
|
| 234 |
+
max_width: Maximum acceptable width (m)
|
| 235 |
+
|
| 236 |
+
Returns:
|
| 237 |
+
(lots, widths) tuple
|
| 238 |
+
"""
|
| 239 |
+
if block.is_empty or not block.is_valid:
|
| 240 |
+
return [], []
|
| 241 |
+
|
| 242 |
+
# Get OBB dimensions
|
| 243 |
+
width, length, _ = get_obb_dimensions(block)
|
| 244 |
+
|
| 245 |
+
# Calculate optimal number of lots
|
| 246 |
+
num_lots = max(1, round(length / target_width))
|
| 247 |
+
|
| 248 |
+
# Calculate actual width
|
| 249 |
+
actual_width = length / num_lots
|
| 250 |
+
|
| 251 |
+
# Adjust if outside bounds
|
| 252 |
+
if actual_width < min_width:
|
| 253 |
+
num_lots = max(1, int(length / min_width))
|
| 254 |
+
actual_width = length / num_lots
|
| 255 |
+
elif actual_width > max_width:
|
| 256 |
+
num_lots = max(1, int(length / max_width) + 1)
|
| 257 |
+
actual_width = length / num_lots
|
| 258 |
+
|
| 259 |
+
lot_widths = [actual_width] * num_lots
|
| 260 |
+
|
| 261 |
+
lots = orthogonal_slice(block, lot_widths)
|
| 262 |
+
|
| 263 |
+
return lots, lot_widths
|
algorithms/backend/core/geometry/shape_quality.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shape quality analysis for aesthetic optimization.
|
| 3 |
+
|
| 4 |
+
Implements geometric quality metrics from Beauti_mode.md:
|
| 5 |
+
- Rectangularity (area / OBB area)
|
| 6 |
+
- Aspect Ratio (length / width)
|
| 7 |
+
- Minimum area constraints
|
| 8 |
+
|
| 9 |
+
Used to filter poor-quality lots and convert them to green spaces.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
from shapely.geometry import Polygon
|
| 14 |
+
from typing import Tuple, Optional
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# Default thresholds from Beauti_mode.md
|
| 18 |
+
DEFAULT_MIN_RECTANGULARITY = 0.75
|
| 19 |
+
DEFAULT_MAX_ASPECT_RATIO = 4.0
|
| 20 |
+
DEFAULT_MIN_LOT_AREA = 1000.0 # m²
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def analyze_shape_quality(
|
| 24 |
+
polygon: Polygon,
|
| 25 |
+
min_rectangularity: float = DEFAULT_MIN_RECTANGULARITY,
|
| 26 |
+
max_aspect_ratio: float = DEFAULT_MAX_ASPECT_RATIO,
|
| 27 |
+
min_area: float = DEFAULT_MIN_LOT_AREA
|
| 28 |
+
) -> Tuple[float, bool]:
|
| 29 |
+
"""
|
| 30 |
+
Analyze shape quality and return aesthetic score with validity status.
|
| 31 |
+
|
| 32 |
+
Uses Oriented Bounding Box (OBB) to calculate metrics:
|
| 33 |
+
- Rectangularity: ratio of polygon area to its OBB area
|
| 34 |
+
- Aspect Ratio: ratio of OBB length to width
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
polygon: Shapely Polygon to analyze
|
| 38 |
+
min_rectangularity: Minimum acceptable rectangularity (0-1)
|
| 39 |
+
max_aspect_ratio: Maximum acceptable length/width ratio
|
| 40 |
+
min_area: Minimum acceptable lot area (m²)
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
(score, is_valid) tuple where:
|
| 44 |
+
- score: Aesthetic score between 0 and 1 (higher = better)
|
| 45 |
+
- is_valid: True if lot meets all quality thresholds
|
| 46 |
+
"""
|
| 47 |
+
if polygon.is_empty or not polygon.is_valid:
|
| 48 |
+
return 0.0, False
|
| 49 |
+
|
| 50 |
+
# Calculate OBB (Oriented Bounding Box)
|
| 51 |
+
obb = polygon.minimum_rotated_rectangle
|
| 52 |
+
|
| 53 |
+
if obb.is_empty or obb.area <= 0:
|
| 54 |
+
return 0.0, False
|
| 55 |
+
|
| 56 |
+
# Rectangularity: how well the polygon fills its OBB
|
| 57 |
+
# Perfect rectangle = 1.0, Triangle ≈ 0.5
|
| 58 |
+
rectangularity = polygon.area / obb.area
|
| 59 |
+
|
| 60 |
+
# Calculate aspect ratio from OBB edges
|
| 61 |
+
x, y = obb.exterior.coords.xy
|
| 62 |
+
|
| 63 |
+
# Get two adjacent edges of OBB
|
| 64 |
+
edge_1 = np.hypot(x[1] - x[0], y[1] - y[0])
|
| 65 |
+
edge_2 = np.hypot(x[2] - x[1], y[2] - y[1])
|
| 66 |
+
|
| 67 |
+
if edge_1 == 0 or edge_2 == 0:
|
| 68 |
+
return 0.0, False
|
| 69 |
+
|
| 70 |
+
# Aspect ratio: longer edge / shorter edge
|
| 71 |
+
width, length = sorted([edge_1, edge_2])
|
| 72 |
+
aspect_ratio = length / width
|
| 73 |
+
|
| 74 |
+
# --- HARD CONSTRAINTS ---
|
| 75 |
+
is_valid = True
|
| 76 |
+
|
| 77 |
+
# Rule 1: Must be reasonably rectangular
|
| 78 |
+
if rectangularity < min_rectangularity:
|
| 79 |
+
is_valid = False
|
| 80 |
+
|
| 81 |
+
# Rule 2: Cannot be too elongated
|
| 82 |
+
if aspect_ratio > max_aspect_ratio:
|
| 83 |
+
is_valid = False
|
| 84 |
+
|
| 85 |
+
# Rule 3: Minimum area to avoid tiny fragments
|
| 86 |
+
if polygon.area < min_area:
|
| 87 |
+
is_valid = False
|
| 88 |
+
|
| 89 |
+
# Calculate aesthetic score (for optimization objective)
|
| 90 |
+
# Higher rectangularity and lower aspect ratio = higher score
|
| 91 |
+
score = (rectangularity * 0.7) + ((1.0 / aspect_ratio) * 0.3)
|
| 92 |
+
|
| 93 |
+
return score, is_valid
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def get_dominant_edge_vector(polygon: Polygon) -> Optional[np.ndarray]:
|
| 97 |
+
"""
|
| 98 |
+
Find the unit vector of the longest edge (typically the frontage).
|
| 99 |
+
|
| 100 |
+
Used for orthogonal alignment - lots should be cut perpendicular
|
| 101 |
+
to this dominant direction.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
polygon: Shapely Polygon to analyze
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
Unit vector (2D numpy array) of the longest edge,
|
| 108 |
+
or None if polygon is invalid
|
| 109 |
+
"""
|
| 110 |
+
if polygon.is_empty or not polygon.is_valid:
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
# Use OBB to find dominant direction
|
| 114 |
+
rect = polygon.minimum_rotated_rectangle
|
| 115 |
+
x, y = rect.exterior.coords.xy
|
| 116 |
+
|
| 117 |
+
# Get first 3 points to find 2 adjacent edges
|
| 118 |
+
p0 = np.array([x[0], y[0]])
|
| 119 |
+
p1 = np.array([x[1], y[1]])
|
| 120 |
+
p2 = np.array([x[2], y[2]])
|
| 121 |
+
|
| 122 |
+
edge1_len = np.linalg.norm(p1 - p0)
|
| 123 |
+
edge2_len = np.linalg.norm(p2 - p1)
|
| 124 |
+
|
| 125 |
+
# Select longer edge
|
| 126 |
+
if edge1_len > edge2_len:
|
| 127 |
+
vec = p1 - p0
|
| 128 |
+
else:
|
| 129 |
+
vec = p2 - p1
|
| 130 |
+
|
| 131 |
+
# Return unit vector
|
| 132 |
+
norm = np.linalg.norm(vec)
|
| 133 |
+
if norm == 0:
|
| 134 |
+
return None
|
| 135 |
+
|
| 136 |
+
return vec / norm
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def get_perpendicular_vector(vector: np.ndarray) -> np.ndarray:
|
| 140 |
+
"""
|
| 141 |
+
Get perpendicular vector (90 degree rotation).
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
vector: 2D numpy array
|
| 145 |
+
|
| 146 |
+
Returns:
|
| 147 |
+
Perpendicular unit vector
|
| 148 |
+
"""
|
| 149 |
+
return np.array([-vector[1], vector[0]])
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def get_obb_dimensions(polygon: Polygon) -> Tuple[float, float, float]:
|
| 153 |
+
"""
|
| 154 |
+
Get oriented bounding box dimensions.
|
| 155 |
+
|
| 156 |
+
Args:
|
| 157 |
+
polygon: Shapely Polygon
|
| 158 |
+
|
| 159 |
+
Returns:
|
| 160 |
+
(width, length, angle_degrees) where width <= length
|
| 161 |
+
"""
|
| 162 |
+
if polygon.is_empty or not polygon.is_valid:
|
| 163 |
+
return 0.0, 0.0, 0.0
|
| 164 |
+
|
| 165 |
+
obb = polygon.minimum_rotated_rectangle
|
| 166 |
+
x, y = obb.exterior.coords.xy
|
| 167 |
+
|
| 168 |
+
# Calculate edges
|
| 169 |
+
edge_1 = np.hypot(x[1] - x[0], y[1] - y[0])
|
| 170 |
+
edge_2 = np.hypot(x[2] - x[1], y[2] - y[1])
|
| 171 |
+
|
| 172 |
+
width, length = sorted([edge_1, edge_2])
|
| 173 |
+
|
| 174 |
+
# Calculate rotation angle of longer edge
|
| 175 |
+
if edge_1 > edge_2:
|
| 176 |
+
dx, dy = x[1] - x[0], y[1] - y[0]
|
| 177 |
+
else:
|
| 178 |
+
dx, dy = x[2] - x[1], y[2] - y[1]
|
| 179 |
+
|
| 180 |
+
angle_rad = np.arctan2(dy, dx)
|
| 181 |
+
angle_deg = np.degrees(angle_rad)
|
| 182 |
+
|
| 183 |
+
return width, length, angle_deg
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def classify_lot_type(
|
| 187 |
+
polygon: Polygon,
|
| 188 |
+
min_rectangularity: float = DEFAULT_MIN_RECTANGULARITY,
|
| 189 |
+
max_aspect_ratio: float = DEFAULT_MAX_ASPECT_RATIO,
|
| 190 |
+
min_area: float = DEFAULT_MIN_LOT_AREA
|
| 191 |
+
) -> str:
|
| 192 |
+
"""
|
| 193 |
+
Classify lot into categories based on shape quality.
|
| 194 |
+
|
| 195 |
+
Args:
|
| 196 |
+
polygon: Lot polygon to classify
|
| 197 |
+
min_rectangularity: Threshold for commercial use
|
| 198 |
+
max_aspect_ratio: Threshold for commercial use
|
| 199 |
+
min_area: Minimum area for any use
|
| 200 |
+
|
| 201 |
+
Returns:
|
| 202 |
+
Classification string:
|
| 203 |
+
- 'commercial': Good shape, suitable for industrial/commercial
|
| 204 |
+
- 'green_space': Poor shape, should be converted to park/utility
|
| 205 |
+
- 'unusable': Too small or invalid
|
| 206 |
+
"""
|
| 207 |
+
if polygon.is_empty or not polygon.is_valid:
|
| 208 |
+
return 'unusable'
|
| 209 |
+
|
| 210 |
+
if polygon.area < min_area:
|
| 211 |
+
return 'unusable'
|
| 212 |
+
|
| 213 |
+
score, is_valid = analyze_shape_quality(
|
| 214 |
+
polygon, min_rectangularity, max_aspect_ratio, min_area
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
if is_valid:
|
| 218 |
+
return 'commercial'
|
| 219 |
+
else:
|
| 220 |
+
return 'green_space'
|
algorithms/backend/core/optimization/grid_optimizer.py
CHANGED
|
@@ -32,7 +32,8 @@ class GridOptimizer:
|
|
| 32 |
self,
|
| 33 |
land_polygon: Polygon,
|
| 34 |
lake_polygon: Optional[Polygon] = None,
|
| 35 |
-
settings: Optional[OptimizationSettings] = None
|
|
|
|
| 36 |
):
|
| 37 |
"""
|
| 38 |
Initialize grid optimizer.
|
|
@@ -41,10 +42,12 @@ class GridOptimizer:
|
|
| 41 |
land_polygon: Main land boundary
|
| 42 |
lake_polygon: Water body to exclude (optional)
|
| 43 |
settings: Optimization settings (uses defaults if None)
|
|
|
|
| 44 |
"""
|
| 45 |
self.land_poly = land_polygon
|
| 46 |
self.lake_poly = lake_polygon or Polygon()
|
| 47 |
self.settings = settings or DEFAULT_SETTINGS.optimization
|
|
|
|
| 48 |
|
| 49 |
self._setup_deap()
|
| 50 |
|
|
@@ -60,37 +63,73 @@ class GridOptimizer:
|
|
| 60 |
|
| 61 |
# Gene definitions
|
| 62 |
spacing_min, spacing_max = self.settings.spacing_bounds
|
| 63 |
-
angle_min, angle_max = self.settings.angle_bounds
|
| 64 |
|
| 65 |
self.toolbox.register("attr_spacing", random.uniform, spacing_min, spacing_max)
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
self.toolbox.register("population", tools.initRepeat, list, self.toolbox.individual)
|
| 76 |
|
|
|
|
| 77 |
# Genetic operators
|
| 78 |
self.toolbox.register("evaluate", self._evaluate_layout)
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
self.toolbox.register("select", tools.selNSGA2)
|
| 95 |
|
| 96 |
def generate_grid_candidates(
|
|
@@ -153,7 +192,12 @@ class GridOptimizer:
|
|
| 153 |
Returns:
|
| 154 |
(total_residential_area, fragmented_blocks)
|
| 155 |
"""
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
blocks = self.generate_grid_candidates(spacing, angle)
|
| 158 |
|
| 159 |
total_residential_area = 0.0
|
|
@@ -235,6 +279,12 @@ class GridOptimizer:
|
|
| 235 |
history.append(list(best_ind))
|
| 236 |
|
| 237 |
final_best = tools.selBest(pop, 1)[0]
|
| 238 |
-
logger.info(f"Optimization complete: spacing={final_best[0]:.2f}, angle={final_best[1]:.2f}")
|
| 239 |
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
self,
|
| 33 |
land_polygon: Polygon,
|
| 34 |
lake_polygon: Optional[Polygon] = None,
|
| 35 |
+
settings: Optional[OptimizationSettings] = None,
|
| 36 |
+
fixed_angle: Optional[float] = None
|
| 37 |
):
|
| 38 |
"""
|
| 39 |
Initialize grid optimizer.
|
|
|
|
| 42 |
land_polygon: Main land boundary
|
| 43 |
lake_polygon: Water body to exclude (optional)
|
| 44 |
settings: Optimization settings (uses defaults if None)
|
| 45 |
+
fixed_angle: Force grid rotation to specific angle (degrees). If None, optimizes angle.
|
| 46 |
"""
|
| 47 |
self.land_poly = land_polygon
|
| 48 |
self.lake_poly = lake_polygon or Polygon()
|
| 49 |
self.settings = settings or DEFAULT_SETTINGS.optimization
|
| 50 |
+
self.fixed_angle = fixed_angle
|
| 51 |
|
| 52 |
self._setup_deap()
|
| 53 |
|
|
|
|
| 63 |
|
| 64 |
# Gene definitions
|
| 65 |
spacing_min, spacing_max = self.settings.spacing_bounds
|
|
|
|
| 66 |
|
| 67 |
self.toolbox.register("attr_spacing", random.uniform, spacing_min, spacing_max)
|
| 68 |
+
|
| 69 |
+
if self.fixed_angle is not None:
|
| 70 |
+
# Fixed angle optimization - only spacing varies
|
| 71 |
+
self.toolbox.register(
|
| 72 |
+
"individual",
|
| 73 |
+
tools.initCycle,
|
| 74 |
+
creator.Individual,
|
| 75 |
+
(self.toolbox.attr_spacing,),
|
| 76 |
+
n=1
|
| 77 |
+
)
|
| 78 |
+
else:
|
| 79 |
+
# Full optimization - spacing and angle
|
| 80 |
+
angle_min, angle_max = self.settings.angle_bounds
|
| 81 |
+
self.toolbox.register("attr_angle", random.uniform, angle_min, angle_max)
|
| 82 |
+
|
| 83 |
+
self.toolbox.register(
|
| 84 |
+
"individual",
|
| 85 |
+
tools.initCycle,
|
| 86 |
+
creator.Individual,
|
| 87 |
+
(self.toolbox.attr_spacing, self.toolbox.attr_angle),
|
| 88 |
+
n=1
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
self.toolbox.register("population", tools.initRepeat, list, self.toolbox.individual)
|
| 92 |
|
| 93 |
+
# Genetic operators
|
| 94 |
# Genetic operators
|
| 95 |
self.toolbox.register("evaluate", self._evaluate_layout)
|
| 96 |
+
|
| 97 |
+
if self.fixed_angle is not None:
|
| 98 |
+
# 1D mutation/mating
|
| 99 |
+
self.toolbox.register(
|
| 100 |
+
"mate",
|
| 101 |
+
tools.cxSimulatedBinaryBounded,
|
| 102 |
+
low=[spacing_min],
|
| 103 |
+
up=[spacing_max],
|
| 104 |
+
eta=self.settings.eta
|
| 105 |
+
)
|
| 106 |
+
self.toolbox.register(
|
| 107 |
+
"mutate",
|
| 108 |
+
tools.mutPolynomialBounded,
|
| 109 |
+
low=[spacing_min],
|
| 110 |
+
up=[spacing_max],
|
| 111 |
+
eta=self.settings.eta,
|
| 112 |
+
indpb=0.2
|
| 113 |
+
)
|
| 114 |
+
else:
|
| 115 |
+
# 2D mutation/mating
|
| 116 |
+
angle_min, angle_max = self.settings.angle_bounds
|
| 117 |
+
self.toolbox.register(
|
| 118 |
+
"mate",
|
| 119 |
+
tools.cxSimulatedBinaryBounded,
|
| 120 |
+
low=[spacing_min, angle_min],
|
| 121 |
+
up=[spacing_max, angle_max],
|
| 122 |
+
eta=self.settings.eta
|
| 123 |
+
)
|
| 124 |
+
self.toolbox.register(
|
| 125 |
+
"mutate",
|
| 126 |
+
tools.mutPolynomialBounded,
|
| 127 |
+
low=[spacing_min, angle_min],
|
| 128 |
+
up=[spacing_max, angle_max],
|
| 129 |
+
eta=self.settings.eta,
|
| 130 |
+
indpb=0.2
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
self.toolbox.register("select", tools.selNSGA2)
|
| 134 |
|
| 135 |
def generate_grid_candidates(
|
|
|
|
| 192 |
Returns:
|
| 193 |
(total_residential_area, fragmented_blocks)
|
| 194 |
"""
|
| 195 |
+
if self.fixed_angle is not None:
|
| 196 |
+
spacing = individual[0]
|
| 197 |
+
angle = self.fixed_angle
|
| 198 |
+
else:
|
| 199 |
+
spacing, angle = individual
|
| 200 |
+
|
| 201 |
blocks = self.generate_grid_candidates(spacing, angle)
|
| 202 |
|
| 203 |
total_residential_area = 0.0
|
|
|
|
| 279 |
history.append(list(best_ind))
|
| 280 |
|
| 281 |
final_best = tools.selBest(pop, 1)[0]
|
|
|
|
| 282 |
|
| 283 |
+
if self.fixed_angle is not None:
|
| 284 |
+
spacing = final_best[0]
|
| 285 |
+
angle = self.fixed_angle
|
| 286 |
+
logger.info(f"Optimization complete (Fixed Angle): spacing={spacing:.2f}, angle={angle:.2f}")
|
| 287 |
+
return [spacing, angle], history
|
| 288 |
+
else:
|
| 289 |
+
logger.info(f"Optimization complete: spacing={final_best[0]:.2f}, angle={final_best[1]:.2f}")
|
| 290 |
+
return list(final_best), history
|
algorithms/backend/core/optimization/subdivision_solver.py
CHANGED
|
@@ -12,7 +12,13 @@ import numpy as np
|
|
| 12 |
from shapely.geometry import Polygon
|
| 13 |
from ortools.sat.python import cp_model
|
| 14 |
|
| 15 |
-
from core.config.settings import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
|
|
@@ -33,17 +39,22 @@ class SubdivisionSolver:
|
|
| 33 |
min_width: float,
|
| 34 |
max_width: float,
|
| 35 |
target_width: float,
|
| 36 |
-
time_limit: float = 5.0
|
|
|
|
| 37 |
) -> List[float]:
|
| 38 |
"""
|
| 39 |
Solve optimal lot widths using constraint programming.
|
| 40 |
|
|
|
|
|
|
|
|
|
|
| 41 |
Args:
|
| 42 |
total_length: Total length to subdivide
|
| 43 |
min_width: Minimum lot width
|
| 44 |
max_width: Maximum lot width
|
| 45 |
target_width: Target lot width
|
| 46 |
time_limit: Solver time limit in seconds
|
|
|
|
| 47 |
|
| 48 |
Returns:
|
| 49 |
List of lot widths
|
|
@@ -70,11 +81,13 @@ class SubdivisionSolver:
|
|
| 70 |
# Estimate number of lots
|
| 71 |
max_lots = int(total_length / min_width) + 1
|
| 72 |
|
|
|
|
|
|
|
| 73 |
# Decision variables: lot widths (scaled to integers for CP)
|
| 74 |
scale = 100 # 1cm precision
|
| 75 |
lot_vars = [
|
| 76 |
model.NewIntVar(
|
| 77 |
-
|
| 78 |
int(max_width * scale),
|
| 79 |
f'lot_{i}'
|
| 80 |
)
|
|
@@ -98,9 +111,11 @@ class SubdivisionSolver:
|
|
| 98 |
model.Add(lot_vars[i] >= int(min_width * scale)).OnlyEnforceIf(used[i])
|
| 99 |
model.Add(lot_vars[i] == 0).OnlyEnforceIf(used[i].Not())
|
| 100 |
|
| 101 |
-
# Objective: Minimize deviation from target
|
|
|
|
|
|
|
| 102 |
deviations = [
|
| 103 |
-
model.NewIntVar(0,
|
| 104 |
for i in range(max_lots)
|
| 105 |
]
|
| 106 |
|
|
@@ -108,7 +123,10 @@ class SubdivisionSolver:
|
|
| 108 |
for i in range(max_lots):
|
| 109 |
model.AddAbsEquality(deviations[i], lot_vars[i] - target_scaled)
|
| 110 |
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
# Solve
|
| 114 |
solver = cp_model.CpSolver()
|
|
@@ -125,7 +143,7 @@ class SubdivisionSolver:
|
|
| 125 |
return widths
|
| 126 |
else:
|
| 127 |
# Fallback: uniform division
|
| 128 |
-
logger.warning("CP solver failed, using uniform fallback")
|
| 129 |
num_lots = max(1, int(total_length / target_width))
|
| 130 |
return [total_length / num_lots] * num_lots
|
| 131 |
|
|
@@ -181,42 +199,33 @@ class SubdivisionSolver:
|
|
| 181 |
# Good blocks become residential/commercial
|
| 182 |
result['type'] = 'residential'
|
| 183 |
|
| 184 |
-
#
|
| 185 |
-
|
| 186 |
-
|
|
|
|
| 187 |
|
| 188 |
# Adaptive time limit based on block size
|
| 189 |
-
adaptive_time = min(time_limit, max(0.5,
|
| 190 |
|
| 191 |
lot_widths = SubdivisionSolver.solve_subdivision(
|
| 192 |
-
|
| 193 |
)
|
| 194 |
|
| 195 |
-
#
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
for
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
if buildable.is_empty or not buildable.is_valid:
|
| 212 |
-
buildable = None
|
| 213 |
-
|
| 214 |
-
result['lots'].append({
|
| 215 |
-
'geometry': clipped,
|
| 216 |
-
'width': width,
|
| 217 |
-
'buildable': buildable
|
| 218 |
-
})
|
| 219 |
-
|
| 220 |
-
current_x += width
|
| 221 |
|
| 222 |
return result
|
|
|
|
| 12 |
from shapely.geometry import Polygon
|
| 13 |
from ortools.sat.python import cp_model
|
| 14 |
|
| 15 |
+
from core.config.settings import (
|
| 16 |
+
SubdivisionSettings,
|
| 17 |
+
DEFAULT_SETTINGS,
|
| 18 |
+
DEVIATION_PENALTY_WEIGHT,
|
| 19 |
+
)
|
| 20 |
+
from core.geometry.orthogonal_slicer import orthogonal_slice
|
| 21 |
+
from core.geometry.shape_quality import get_obb_dimensions
|
| 22 |
|
| 23 |
logger = logging.getLogger(__name__)
|
| 24 |
|
|
|
|
| 39 |
min_width: float,
|
| 40 |
max_width: float,
|
| 41 |
target_width: float,
|
| 42 |
+
time_limit: float = 5.0,
|
| 43 |
+
deviation_penalty_weight: float = DEVIATION_PENALTY_WEIGHT
|
| 44 |
) -> List[float]:
|
| 45 |
"""
|
| 46 |
Solve optimal lot widths using constraint programming.
|
| 47 |
|
| 48 |
+
Uses weighted objective from Beauti_mode.md:
|
| 49 |
+
Maximize(sum(widths) * 100 - sum(deviations) * penalty_weight)
|
| 50 |
+
|
| 51 |
Args:
|
| 52 |
total_length: Total length to subdivide
|
| 53 |
min_width: Minimum lot width
|
| 54 |
max_width: Maximum lot width
|
| 55 |
target_width: Target lot width
|
| 56 |
time_limit: Solver time limit in seconds
|
| 57 |
+
deviation_penalty_weight: Weight for deviation penalty (higher = more uniform)
|
| 58 |
|
| 59 |
Returns:
|
| 60 |
List of lot widths
|
|
|
|
| 81 |
# Estimate number of lots
|
| 82 |
max_lots = int(total_length / min_width) + 1
|
| 83 |
|
| 84 |
+
logger.debug(f"Solving subdivision: Length={total_length:.2f}, Min={min_width}, Max={max_width}, Target={target_width}")
|
| 85 |
+
|
| 86 |
# Decision variables: lot widths (scaled to integers for CP)
|
| 87 |
scale = 100 # 1cm precision
|
| 88 |
lot_vars = [
|
| 89 |
model.NewIntVar(
|
| 90 |
+
0,
|
| 91 |
int(max_width * scale),
|
| 92 |
f'lot_{i}'
|
| 93 |
)
|
|
|
|
| 111 |
model.Add(lot_vars[i] >= int(min_width * scale)).OnlyEnforceIf(used[i])
|
| 112 |
model.Add(lot_vars[i] == 0).OnlyEnforceIf(used[i].Not())
|
| 113 |
|
| 114 |
+
# Objective: Minimize deviation from target (weighted approach from Beauti_mode)
|
| 115 |
+
# Deviation bound must accommodate unused lots (0 width -> deviation = target_width)
|
| 116 |
+
dev_upper_bound = int(max(max_width, target_width) * 2 * scale)
|
| 117 |
deviations = [
|
| 118 |
+
model.NewIntVar(0, dev_upper_bound, f'dev_{i}')
|
| 119 |
for i in range(max_lots)
|
| 120 |
]
|
| 121 |
|
|
|
|
| 123 |
for i in range(max_lots):
|
| 124 |
model.AddAbsEquality(deviations[i], lot_vars[i] - target_scaled)
|
| 125 |
|
| 126 |
+
# Enhanced objective: Maximize area while penalizing deviation (Beauti_mode Section 4)
|
| 127 |
+
# Higher deviation_penalty_weight = more uniform lot sizes
|
| 128 |
+
penalty_weight = int(deviation_penalty_weight)
|
| 129 |
+
model.Maximize(sum(lot_vars) * 100 - sum(deviations) * penalty_weight)
|
| 130 |
|
| 131 |
# Solve
|
| 132 |
solver = cp_model.CpSolver()
|
|
|
|
| 143 |
return widths
|
| 144 |
else:
|
| 145 |
# Fallback: uniform division
|
| 146 |
+
logger.warning(f"CP solver failed (Status: {solver.StatusName(status)}), using uniform fallback")
|
| 147 |
num_lots = max(1, int(total_length / target_width))
|
| 148 |
return [total_length / num_lots] * num_lots
|
| 149 |
|
|
|
|
| 199 |
# Good blocks become residential/commercial
|
| 200 |
result['type'] = 'residential'
|
| 201 |
|
| 202 |
+
# Use OBB length for subdivision (handles rotated blocks)
|
| 203 |
+
# width is shorter dim, length is longer dim.
|
| 204 |
+
# We assume we cut along the dominant edge (length).
|
| 205 |
+
_, total_length, _ = get_obb_dimensions(block_geom)
|
| 206 |
|
| 207 |
# Adaptive time limit based on block size
|
| 208 |
+
adaptive_time = min(time_limit, max(0.5, total_length / 100))
|
| 209 |
|
| 210 |
lot_widths = SubdivisionSolver.solve_subdivision(
|
| 211 |
+
total_length, min_width, max_width, target_width, adaptive_time
|
| 212 |
)
|
| 213 |
|
| 214 |
+
# Use Orthogonal Slicer to generate lot geometries
|
| 215 |
+
raw_lots = orthogonal_slice(block_geom, lot_widths)
|
| 216 |
+
|
| 217 |
+
for lot_poly in raw_lots:
|
| 218 |
+
# Calculate setback (buildable area)
|
| 219 |
+
# simplify(0.1) handles minor artifacts from rotation/buffering
|
| 220 |
+
buildable = lot_poly.buffer(-setback_dist).simplify(0.1)
|
| 221 |
+
|
| 222 |
+
if buildable.is_empty or not buildable.is_valid:
|
| 223 |
+
buildable = None
|
| 224 |
+
|
| 225 |
+
result['lots'].append({
|
| 226 |
+
'geometry': lot_poly,
|
| 227 |
+
'width': lot_poly.area / total_length * len(lot_widths), # Approx width
|
| 228 |
+
'buildable': buildable
|
| 229 |
+
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
return result
|
algorithms/backend/pipeline/land_redistribution.py
CHANGED
|
@@ -11,6 +11,7 @@ import logging
|
|
| 11 |
import random
|
| 12 |
from typing import List, Dict, Any, Tuple, Optional
|
| 13 |
|
|
|
|
| 14 |
import numpy as np
|
| 15 |
from shapely.geometry import Polygon, Point, mapping
|
| 16 |
from shapely.ops import unary_union
|
|
@@ -19,11 +20,15 @@ from core.config.settings import (
|
|
| 19 |
AlgorithmSettings,
|
| 20 |
DEFAULT_SETTINGS,
|
| 21 |
ROAD_MAIN_WIDTH,
|
| 22 |
-
ROAD_INTERNAL_WIDTH,
|
| 23 |
SIDEWALK_WIDTH,
|
| 24 |
TURNING_RADIUS,
|
| 25 |
SERVICE_AREA_RATIO,
|
| 26 |
MIN_BLOCK_AREA,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
)
|
| 28 |
from core.geometry.polygon_utils import (
|
| 29 |
get_elevation,
|
|
@@ -31,6 +36,11 @@ from core.geometry.polygon_utils import (
|
|
| 31 |
filter_by_min_area,
|
| 32 |
sort_by_elevation,
|
| 33 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
from core.geometry.voronoi import (
|
| 35 |
generate_voronoi_seeds,
|
| 36 |
create_voronoi_diagram,
|
|
@@ -168,8 +178,22 @@ class LandRedistributionPipeline:
|
|
| 168 |
return smooth_network, service_blocks, commercial_blocks
|
| 169 |
|
| 170 |
def run_stage1(self) -> Dict[str, Any]:
|
| 171 |
-
"""Run grid optimization stage (NSGA-II)."""
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
best_solution, history = optimizer.optimize(
|
| 175 |
population_size=self.config.get('population_size', 30),
|
|
@@ -179,12 +203,28 @@ class LandRedistributionPipeline:
|
|
| 179 |
spacing, angle = best_solution
|
| 180 |
blocks = optimizer.generate_grid_candidates(spacing, angle)
|
| 181 |
|
| 182 |
-
# Filter to usable blocks
|
| 183 |
usable_blocks = []
|
|
|
|
|
|
|
|
|
|
| 184 |
for blk in blocks:
|
|
|
|
| 185 |
intersection = blk.intersection(self.land_poly).difference(self.lake_poly)
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
return {
|
| 190 |
'spacing': spacing,
|
|
@@ -203,9 +243,10 @@ class LandRedistributionPipeline:
|
|
| 203 |
blocks: List[Polygon],
|
| 204 |
spacing: float
|
| 205 |
) -> Dict[str, Any]:
|
| 206 |
-
"""Run subdivision stage (OR-Tools)."""
|
| 207 |
all_lots = []
|
| 208 |
parks = []
|
|
|
|
| 209 |
|
| 210 |
for block in blocks:
|
| 211 |
result = SubdivisionSolver.subdivide_block(
|
|
@@ -220,16 +261,46 @@ class LandRedistributionPipeline:
|
|
| 220 |
if result['type'] == 'park':
|
| 221 |
parks.append(result['geometry'])
|
| 222 |
else:
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
avg_width = np.mean([lot['width'] for lot in all_lots]) if all_lots else 0
|
| 226 |
|
| 227 |
return {
|
| 228 |
'lots': all_lots,
|
| 229 |
'parks': parks,
|
|
|
|
| 230 |
'metrics': {
|
| 231 |
'total_lots': len(all_lots),
|
| 232 |
'total_parks': len(parks),
|
|
|
|
| 233 |
'avg_lot_width': avg_width
|
| 234 |
}
|
| 235 |
}
|
|
@@ -259,12 +330,40 @@ class LandRedistributionPipeline:
|
|
| 259 |
accumulated += xlnt.area
|
| 260 |
|
| 261 |
# Fill remaining service quota
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
else:
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
return {
|
| 270 |
'xlnt': xlnt_block,
|
|
@@ -272,17 +371,52 @@ class LandRedistributionPipeline:
|
|
| 272 |
'commercial': commercial_blocks
|
| 273 |
}
|
| 274 |
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
stage1_result = self.run_stage1()
|
| 287 |
classification = self.classify_blocks(stage1_result['blocks'])
|
| 288 |
commercial_blocks_voronoi = classification['commercial']
|
|
@@ -292,15 +426,14 @@ class LandRedistributionPipeline:
|
|
| 292 |
road_network = self.land_poly.difference(unary_union(all_blocks))
|
| 293 |
spacing_for_subdivision = stage1_result['spacing']
|
| 294 |
else:
|
| 295 |
-
# Separate XLNT from service blocks
|
| 296 |
if service_blocks_voronoi:
|
| 297 |
xlnt_blocks = [service_blocks_voronoi[0]]
|
| 298 |
service_blocks_voronoi = service_blocks_voronoi[1:]
|
| 299 |
-
else:
|
| 300 |
-
xlnt_blocks = []
|
| 301 |
|
| 302 |
# Estimate spacing for subdivision
|
| 303 |
if commercial_blocks_voronoi:
|
|
|
|
| 304 |
avg_area = sum(b.area for b in commercial_blocks_voronoi) / len(commercial_blocks_voronoi)
|
| 305 |
spacing_for_subdivision = max(20.0, (avg_area ** 0.5) * 0.7)
|
| 306 |
else:
|
|
@@ -353,6 +486,7 @@ class LandRedistributionPipeline:
|
|
| 353 |
'road_network': mapping(road_network)
|
| 354 |
},
|
| 355 |
'total_lots': stage2_result['metrics']['total_lots'],
|
| 356 |
-
'
|
| 357 |
-
'
|
|
|
|
| 358 |
}
|
|
|
|
| 11 |
import random
|
| 12 |
from typing import List, Dict, Any, Tuple, Optional
|
| 13 |
|
| 14 |
+
import math
|
| 15 |
import numpy as np
|
| 16 |
from shapely.geometry import Polygon, Point, mapping
|
| 17 |
from shapely.ops import unary_union
|
|
|
|
| 20 |
AlgorithmSettings,
|
| 21 |
DEFAULT_SETTINGS,
|
| 22 |
ROAD_MAIN_WIDTH,
|
| 23 |
+
ROAD_INTERNAL_WIDTH, # This is usually the road width between blocks
|
| 24 |
SIDEWALK_WIDTH,
|
| 25 |
TURNING_RADIUS,
|
| 26 |
SERVICE_AREA_RATIO,
|
| 27 |
MIN_BLOCK_AREA,
|
| 28 |
+
ENABLE_LEFTOVER_MANAGEMENT,
|
| 29 |
+
MIN_RECTANGULARITY,
|
| 30 |
+
MAX_ASPECT_RATIO,
|
| 31 |
+
MIN_LOT_AREA,
|
| 32 |
)
|
| 33 |
from core.geometry.polygon_utils import (
|
| 34 |
get_elevation,
|
|
|
|
| 36 |
filter_by_min_area,
|
| 37 |
sort_by_elevation,
|
| 38 |
)
|
| 39 |
+
from core.geometry.shape_quality import (
|
| 40 |
+
analyze_shape_quality,
|
| 41 |
+
classify_lot_type,
|
| 42 |
+
get_dominant_edge_vector,
|
| 43 |
+
)
|
| 44 |
from core.geometry.voronoi import (
|
| 45 |
generate_voronoi_seeds,
|
| 46 |
create_voronoi_diagram,
|
|
|
|
| 178 |
return smooth_network, service_blocks, commercial_blocks
|
| 179 |
|
| 180 |
def run_stage1(self) -> Dict[str, Any]:
|
| 181 |
+
"""Run grid optimization stage (NSGA-II) with orthogonal alignment."""
|
| 182 |
+
|
| 183 |
+
# Calculate dominant edge angle for orthogonal alignment
|
| 184 |
+
# This addresses User feedback about "uneven blocks" in Stage 1
|
| 185 |
+
dom_vec = get_dominant_edge_vector(self.land_poly)
|
| 186 |
+
# atan2 returns radians between -pi and pi
|
| 187 |
+
fixed_angle = math.degrees(math.atan2(dom_vec[1], dom_vec[0]))
|
| 188 |
+
|
| 189 |
+
logger.info(f"Enforcing orthogonal alignment: {fixed_angle:.2f} degrees (Vector: {dom_vec})")
|
| 190 |
+
|
| 191 |
+
optimizer = GridOptimizer(
|
| 192 |
+
self.land_poly,
|
| 193 |
+
self.lake_poly,
|
| 194 |
+
fixed_angle=fixed_angle,
|
| 195 |
+
settings=self.settings.optimization
|
| 196 |
+
)
|
| 197 |
|
| 198 |
best_solution, history = optimizer.optimize(
|
| 199 |
population_size=self.config.get('population_size', 30),
|
|
|
|
| 203 |
spacing, angle = best_solution
|
| 204 |
blocks = optimizer.generate_grid_candidates(spacing, angle)
|
| 205 |
|
| 206 |
+
# Filter to usable blocks and apply road buffer
|
| 207 |
usable_blocks = []
|
| 208 |
+
road_width = self.config.get('road_width', ROAD_INTERNAL_WIDTH)
|
| 209 |
+
buffer_amount = -road_width / 2.0
|
| 210 |
+
|
| 211 |
for blk in blocks:
|
| 212 |
+
# Intersect with land
|
| 213 |
intersection = blk.intersection(self.land_poly).difference(self.lake_poly)
|
| 214 |
+
|
| 215 |
+
if not intersection.is_empty and intersection.area > MIN_BLOCK_AREA:
|
| 216 |
+
# Apply negative buffer to create road gaps
|
| 217 |
+
# simplify(0.1) helps clean up artifacts after buffering
|
| 218 |
+
buffered_blk = intersection.buffer(buffer_amount, join_style=2).simplify(0.1)
|
| 219 |
+
|
| 220 |
+
if not buffered_blk.is_empty:
|
| 221 |
+
if buffered_blk.geom_type == 'Polygon':
|
| 222 |
+
if buffered_blk.area > MIN_BLOCK_AREA:
|
| 223 |
+
usable_blocks.append(buffered_blk)
|
| 224 |
+
elif buffered_blk.geom_type == 'MultiPolygon':
|
| 225 |
+
for part in buffered_blk.geoms:
|
| 226 |
+
if part.area > MIN_BLOCK_AREA:
|
| 227 |
+
usable_blocks.append(part)
|
| 228 |
|
| 229 |
return {
|
| 230 |
'spacing': spacing,
|
|
|
|
| 243 |
blocks: List[Polygon],
|
| 244 |
spacing: float
|
| 245 |
) -> Dict[str, Any]:
|
| 246 |
+
"""Run subdivision stage (OR-Tools) with leftover management."""
|
| 247 |
all_lots = []
|
| 248 |
parks = []
|
| 249 |
+
green_spaces = [] # NEW: collect poor-quality lots (Beauti_mode Section 3)
|
| 250 |
|
| 251 |
for block in blocks:
|
| 252 |
result = SubdivisionSolver.subdivide_block(
|
|
|
|
| 261 |
if result['type'] == 'park':
|
| 262 |
parks.append(result['geometry'])
|
| 263 |
else:
|
| 264 |
+
# Apply leftover management (Beauti_mode Section 3)
|
| 265 |
+
block_total = len(result['lots'])
|
| 266 |
+
kept_count = 0
|
| 267 |
+
green_count = 0
|
| 268 |
+
|
| 269 |
+
for lot_info in result['lots']:
|
| 270 |
+
lot_geom = lot_info['geometry']
|
| 271 |
+
|
| 272 |
+
if ENABLE_LEFTOVER_MANAGEMENT:
|
| 273 |
+
lot_type = classify_lot_type(
|
| 274 |
+
lot_geom,
|
| 275 |
+
min_rectangularity=MIN_RECTANGULARITY,
|
| 276 |
+
max_aspect_ratio=MAX_ASPECT_RATIO,
|
| 277 |
+
min_area=MIN_LOT_AREA
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
if lot_type == 'commercial':
|
| 281 |
+
all_lots.append(lot_info)
|
| 282 |
+
kept_count += 1
|
| 283 |
+
elif lot_type == 'green_space':
|
| 284 |
+
green_spaces.append(lot_geom)
|
| 285 |
+
green_count += 1
|
| 286 |
+
# 'unusable' lots are discarded
|
| 287 |
+
else:
|
| 288 |
+
all_lots.append(lot_info)
|
| 289 |
+
kept_count += 1
|
| 290 |
+
|
| 291 |
+
if block_total > 0:
|
| 292 |
+
logger.info(f"Block Subdivision: Generated {block_total} lots -> Kept {kept_count} Commercial, {green_count} Green Space")
|
| 293 |
+
|
| 294 |
avg_width = np.mean([lot['width'] for lot in all_lots]) if all_lots else 0
|
| 295 |
|
| 296 |
return {
|
| 297 |
'lots': all_lots,
|
| 298 |
'parks': parks,
|
| 299 |
+
'green_spaces': green_spaces, # NEW field
|
| 300 |
'metrics': {
|
| 301 |
'total_lots': len(all_lots),
|
| 302 |
'total_parks': len(parks),
|
| 303 |
+
'total_green_spaces': len(green_spaces),
|
| 304 |
'avg_lot_width': avg_width
|
| 305 |
}
|
| 306 |
}
|
|
|
|
| 330 |
accumulated += xlnt.area
|
| 331 |
|
| 332 |
# Fill remaining service quota
|
| 333 |
+
# Distribute service blocks (Interleave)
|
| 334 |
+
# Instead of taking the first N blocks, we distribute them evenly
|
| 335 |
+
# to avoid "clumping" of service areas.
|
| 336 |
+
|
| 337 |
+
remaining_blocks = sorted_blocks # These are already sorted by elevation (low -> high)
|
| 338 |
+
num_remaining = len(remaining_blocks)
|
| 339 |
+
|
| 340 |
+
if num_remaining > 0:
|
| 341 |
+
# Calculate how many service blocks we need
|
| 342 |
+
# We use checks against area, but let's approximate by count for mixing
|
| 343 |
+
avg_area = sum(b.area for b in remaining_blocks) / num_remaining
|
| 344 |
+
service_count = int(service_target / avg_area)
|
| 345 |
+
service_count = max(1, min(service_count, int(num_remaining * 0.3))) # Cap at 30%
|
| 346 |
+
|
| 347 |
+
if service_count >= num_remaining:
|
| 348 |
+
service_blocks.extend(remaining_blocks)
|
| 349 |
+
logger.warning(f"Classification: All {num_remaining} blocks assigned to Service (Count={service_count})")
|
| 350 |
else:
|
| 351 |
+
# Step size for distribution
|
| 352 |
+
step = num_remaining / service_count
|
| 353 |
+
indices = [int(i * step) for i in range(service_count)]
|
| 354 |
+
|
| 355 |
+
logger.info(f"Classification: Total={num_remaining}, ServiceTarget={service_count}, Step={step:.2f}")
|
| 356 |
+
|
| 357 |
+
for i, block in enumerate(remaining_blocks):
|
| 358 |
+
if i in indices:
|
| 359 |
+
service_blocks.append(block)
|
| 360 |
+
else:
|
| 361 |
+
commercial_blocks.append(block)
|
| 362 |
+
else:
|
| 363 |
+
# Should not happen if blocks exist
|
| 364 |
+
pass
|
| 365 |
+
|
| 366 |
+
logger.info(f"Classification Result: XLNT={len(xlnt_block)}, Service={len(service_blocks)}, Commercial={len(commercial_blocks)}")
|
| 367 |
|
| 368 |
return {
|
| 369 |
'xlnt': xlnt_block,
|
|
|
|
| 371 |
'commercial': commercial_blocks
|
| 372 |
}
|
| 373 |
|
| 374 |
+
@staticmethod
|
| 375 |
+
def _safe_coords(geom):
|
| 376 |
+
"""Helper to safely extract coordinates for JSON serialization."""
|
| 377 |
+
if geom.geom_type == 'Polygon':
|
| 378 |
+
return list(geom.exterior.coords)
|
| 379 |
+
elif geom.geom_type == 'MultiPolygon':
|
| 380 |
+
# Return exterior of the largest part
|
| 381 |
+
largest = max(geom.geoms, key=lambda p: p.area)
|
| 382 |
+
return list(largest.exterior.coords)
|
| 383 |
+
return []
|
| 384 |
+
|
| 385 |
+
def run_full_pipeline(
|
| 386 |
+
self,
|
| 387 |
+
layout_method: str = 'auto', # 'auto', 'voronoi', 'grid'
|
| 388 |
+
num_seeds: int = 15
|
| 389 |
+
) -> Dict[str, Any]:
|
| 390 |
+
"""
|
| 391 |
+
Run complete optimization pipeline.
|
| 392 |
|
| 393 |
+
Args:
|
| 394 |
+
layout_method: Strategy for road network ('voronoi' or 'grid')
|
| 395 |
+
num_seeds: Number of seeds for Voronoi generation
|
| 396 |
+
"""
|
| 397 |
+
logger.info(f"Starting full pipeline with method: {layout_method}")
|
| 398 |
+
|
| 399 |
+
road_network = Polygon()
|
| 400 |
+
service_blocks_voronoi = []
|
| 401 |
+
commercial_blocks_voronoi = []
|
| 402 |
+
xlnt_blocks = []
|
| 403 |
+
spacing_for_subdivision = 25.0
|
| 404 |
+
|
| 405 |
+
# Stage 0: Voronoi Road Network (if selected)
|
| 406 |
+
if layout_method in ['auto', 'voronoi']:
|
| 407 |
+
road_network, service_blocks_voronoi, commercial_blocks_voronoi = \
|
| 408 |
+
self.generate_road_network(num_seeds=num_seeds)
|
| 409 |
+
|
| 410 |
+
# Determine if we should use Grid (fallback or forced)
|
| 411 |
+
use_grid = False
|
| 412 |
+
if layout_method == 'grid':
|
| 413 |
+
use_grid = True
|
| 414 |
+
elif layout_method == 'auto' and not commercial_blocks_voronoi:
|
| 415 |
+
logger.info("Voronoi failed or produced no blocks, switching to grid-based")
|
| 416 |
+
use_grid = True
|
| 417 |
+
|
| 418 |
+
if use_grid:
|
| 419 |
+
logger.info("Using Grid-based generation (Stage 1)")
|
| 420 |
stage1_result = self.run_stage1()
|
| 421 |
classification = self.classify_blocks(stage1_result['blocks'])
|
| 422 |
commercial_blocks_voronoi = classification['commercial']
|
|
|
|
| 426 |
road_network = self.land_poly.difference(unary_union(all_blocks))
|
| 427 |
spacing_for_subdivision = stage1_result['spacing']
|
| 428 |
else:
|
| 429 |
+
# Separate XLNT from service blocks for Voronoi path
|
| 430 |
if service_blocks_voronoi:
|
| 431 |
xlnt_blocks = [service_blocks_voronoi[0]]
|
| 432 |
service_blocks_voronoi = service_blocks_voronoi[1:]
|
|
|
|
|
|
|
| 433 |
|
| 434 |
# Estimate spacing for subdivision
|
| 435 |
if commercial_blocks_voronoi:
|
| 436 |
+
# Use a heuristic for Voronoi block spacing
|
| 437 |
avg_area = sum(b.area for b in commercial_blocks_voronoi) / len(commercial_blocks_voronoi)
|
| 438 |
spacing_for_subdivision = max(20.0, (avg_area ** 0.5) * 0.7)
|
| 439 |
else:
|
|
|
|
| 486 |
'road_network': mapping(road_network)
|
| 487 |
},
|
| 488 |
'total_lots': stage2_result['metrics']['total_lots'],
|
| 489 |
+
'total_lots': stage2_result['metrics']['total_lots'],
|
| 490 |
+
'service_blocks': [self._safe_coords(b) for b in service_blocks_voronoi],
|
| 491 |
+
'xlnt_blocks': [self._safe_coords(b) for b in xlnt_blocks]
|
| 492 |
}
|
algorithms/backend/test_aesthetic.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for aesthetic optimization functions."""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
import traceback
|
| 5 |
+
|
| 6 |
+
def test_shape_quality():
|
| 7 |
+
"""Test shape quality analysis functions."""
|
| 8 |
+
print("Testing shape quality functions...")
|
| 9 |
+
try:
|
| 10 |
+
from shapely.geometry import Polygon
|
| 11 |
+
from core.geometry.shape_quality import (
|
| 12 |
+
analyze_shape_quality,
|
| 13 |
+
get_dominant_edge_vector,
|
| 14 |
+
classify_lot_type,
|
| 15 |
+
get_obb_dimensions
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
# Test 1: Perfect rectangle should have high score and be valid
|
| 19 |
+
# Must be > 1000 m² to pass min area check
|
| 20 |
+
rect = Polygon([(0, 0), (50, 0), (50, 40), (0, 40)]) # 2000 m²
|
| 21 |
+
score, valid = analyze_shape_quality(rect)
|
| 22 |
+
print(f" Rectangle (2000m²): score={score:.3f}, valid={valid}")
|
| 23 |
+
assert valid, "Rectangle should be valid"
|
| 24 |
+
assert score > 0.8, "Rectangle should have high score"
|
| 25 |
+
|
| 26 |
+
# Test 2: Triangle should have low rectangularity and be invalid
|
| 27 |
+
tri = Polygon([(0, 0), (40, 0), (20, 30)])
|
| 28 |
+
score, valid = analyze_shape_quality(tri)
|
| 29 |
+
print(f" Triangle: score={score:.3f}, valid={valid}")
|
| 30 |
+
assert not valid, "Triangle should be invalid (low rectangularity)"
|
| 31 |
+
|
| 32 |
+
# Test 3: Very elongated shape should be invalid
|
| 33 |
+
elongated = Polygon([(0, 0), (200, 0), (200, 10), (0, 10)])
|
| 34 |
+
score, valid = analyze_shape_quality(elongated)
|
| 35 |
+
print(f" Elongated: score={score:.3f}, valid={valid}")
|
| 36 |
+
assert not valid, "Elongated shape should be invalid"
|
| 37 |
+
|
| 38 |
+
# Test 4: Small lot should be invalid
|
| 39 |
+
small = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]) # 100 m²
|
| 40 |
+
score, valid = analyze_shape_quality(small)
|
| 41 |
+
print(f" Small lot: score={score:.3f}, valid={valid}")
|
| 42 |
+
assert not valid, "Small lot should be invalid"
|
| 43 |
+
|
| 44 |
+
# Test 5: Dominant edge vector
|
| 45 |
+
vec = get_dominant_edge_vector(rect)
|
| 46 |
+
print(f" Dominant edge vector: {vec}")
|
| 47 |
+
assert vec is not None, "Should return vector"
|
| 48 |
+
|
| 49 |
+
# Test 6: OBB dimensions
|
| 50 |
+
w, l, angle = get_obb_dimensions(rect)
|
| 51 |
+
print(f" OBB dimensions: width={w:.1f}, length={l:.1f}, angle={angle:.1f}°")
|
| 52 |
+
assert abs(w - 40) < 1, "Width should be ~40"
|
| 53 |
+
assert abs(l - 50) < 1, "Length should be ~50"
|
| 54 |
+
|
| 55 |
+
# Test 7: Lot classification
|
| 56 |
+
lot_type = classify_lot_type(rect)
|
| 57 |
+
print(f" Rectangle type: {lot_type}")
|
| 58 |
+
assert lot_type == 'commercial', "Good lot should be commercial"
|
| 59 |
+
|
| 60 |
+
# Triangle must be large enough (>1000m²) to be green_space, otherwise it's unusable
|
| 61 |
+
large_tri = Polygon([(0, 0), (60, 0), (30, 50)]) # 1500 m²
|
| 62 |
+
lot_type = classify_lot_type(large_tri)
|
| 63 |
+
print(f" Triangle type: {lot_type}")
|
| 64 |
+
assert lot_type == 'green_space', "Bad lot (large enough) should be green_space"
|
| 65 |
+
|
| 66 |
+
print("✅ Shape quality tests passed")
|
| 67 |
+
return True
|
| 68 |
+
except Exception as e:
|
| 69 |
+
print(f"❌ Shape quality test failed: {e}")
|
| 70 |
+
traceback.print_exc()
|
| 71 |
+
return False
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_orthogonal_slicer():
|
| 75 |
+
"""Test orthogonal slicing functions."""
|
| 76 |
+
print("\nTesting orthogonal slicer functions...")
|
| 77 |
+
try:
|
| 78 |
+
from shapely.geometry import Polygon
|
| 79 |
+
from core.geometry.orthogonal_slicer import (
|
| 80 |
+
orthogonal_slice,
|
| 81 |
+
subdivide_with_uniform_widths
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# Test 1: Subdivide a rectangular block
|
| 85 |
+
block = Polygon([(0, 0), (100, 0), (100, 50), (0, 50)])
|
| 86 |
+
widths = [25, 25, 25, 25]
|
| 87 |
+
|
| 88 |
+
lots = orthogonal_slice(block, widths)
|
| 89 |
+
print(f" Orthogonal slice: {len(lots)} lots from 4 widths")
|
| 90 |
+
assert len(lots) == 4, "Should create 4 lots"
|
| 91 |
+
|
| 92 |
+
# Test 2: Uniform width subdivision
|
| 93 |
+
lots2, actual_widths = subdivide_with_uniform_widths(block, target_width=20)
|
| 94 |
+
print(f" Uniform subdivision: {len(lots2)} lots, widths={[round(w,1) for w in actual_widths]}")
|
| 95 |
+
assert len(lots2) > 0, "Should create lots"
|
| 96 |
+
|
| 97 |
+
print("✅ Orthogonal slicer tests passed")
|
| 98 |
+
return True
|
| 99 |
+
except Exception as e:
|
| 100 |
+
print(f"❌ Orthogonal slicer test failed: {e}")
|
| 101 |
+
traceback.print_exc()
|
| 102 |
+
return False
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_settings():
|
| 106 |
+
"""Test aesthetic settings in config."""
|
| 107 |
+
print("\nTesting aesthetic settings...")
|
| 108 |
+
try:
|
| 109 |
+
from core.config.settings import (
|
| 110 |
+
DEFAULT_SETTINGS,
|
| 111 |
+
MIN_RECTANGULARITY,
|
| 112 |
+
MAX_ASPECT_RATIO,
|
| 113 |
+
MIN_LOT_AREA,
|
| 114 |
+
DEVIATION_PENALTY_WEIGHT,
|
| 115 |
+
ENABLE_LEFTOVER_MANAGEMENT
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
print(f" MIN_RECTANGULARITY = {MIN_RECTANGULARITY}")
|
| 119 |
+
print(f" MAX_ASPECT_RATIO = {MAX_ASPECT_RATIO}")
|
| 120 |
+
print(f" MIN_LOT_AREA = {MIN_LOT_AREA}")
|
| 121 |
+
print(f" DEVIATION_PENALTY_WEIGHT = {DEVIATION_PENALTY_WEIGHT}")
|
| 122 |
+
print(f" ENABLE_LEFTOVER_MANAGEMENT = {ENABLE_LEFTOVER_MANAGEMENT}")
|
| 123 |
+
|
| 124 |
+
assert MIN_RECTANGULARITY == 0.75, "Should match Beauti_mode spec"
|
| 125 |
+
assert MAX_ASPECT_RATIO == 4.0, "Should match Beauti_mode spec"
|
| 126 |
+
assert MIN_LOT_AREA == 1000.0, "Should match Beauti_mode spec"
|
| 127 |
+
|
| 128 |
+
print("✅ Settings tests passed")
|
| 129 |
+
return True
|
| 130 |
+
except Exception as e:
|
| 131 |
+
print(f"❌ Settings test failed: {e}")
|
| 132 |
+
traceback.print_exc()
|
| 133 |
+
return False
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
if __name__ == "__main__":
|
| 137 |
+
print("=" * 50)
|
| 138 |
+
print("Aesthetic Optimization Test Suite")
|
| 139 |
+
print("=" * 50)
|
| 140 |
+
|
| 141 |
+
results = []
|
| 142 |
+
results.append(("Shape Quality", test_shape_quality()))
|
| 143 |
+
results.append(("Orthogonal Slicer", test_orthogonal_slicer()))
|
| 144 |
+
results.append(("Settings", test_settings()))
|
| 145 |
+
|
| 146 |
+
print("\n" + "=" * 50)
|
| 147 |
+
print("Test Results:")
|
| 148 |
+
print("=" * 50)
|
| 149 |
+
|
| 150 |
+
for name, passed in results:
|
| 151 |
+
status = "✅ PASS" if passed else "❌ FAIL"
|
| 152 |
+
print(f"{status} - {name}")
|
| 153 |
+
|
| 154 |
+
all_passed = all(r[1] for r in results)
|
| 155 |
+
|
| 156 |
+
print("\n" + "=" * 50)
|
| 157 |
+
if all_passed:
|
| 158 |
+
print("✅ ALL AESTHETIC TESTS PASSED")
|
| 159 |
+
sys.exit(0)
|
| 160 |
+
else:
|
| 161 |
+
print("❌ SOME TESTS FAILED")
|
| 162 |
+
sys.exit(1)
|
algorithms/backend/test_real_dxf.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test script to run aesthetic optimization on real DXF example.
|
| 3 |
+
Target: examples/663409.dxf
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
# Add current directory to path
|
| 11 |
+
sys.path.append(os.getcwd())
|
| 12 |
+
|
| 13 |
+
from utils.dxf_utils import load_boundary_from_dxf
|
| 14 |
+
from pipeline.land_redistribution import LandRedistributionPipeline
|
| 15 |
+
from core.geometry.shape_quality import analyze_shape_quality
|
| 16 |
+
|
| 17 |
+
logging.basicConfig(level=logging.INFO)
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
def test_dxf_optimization():
|
| 21 |
+
dxf_path = "../../examples/663409.dxf"
|
| 22 |
+
|
| 23 |
+
if not os.path.exists(dxf_path):
|
| 24 |
+
logger.error(f"DXF file not found: {dxf_path}")
|
| 25 |
+
return
|
| 26 |
+
|
| 27 |
+
logger.info(f"Loading DXF: {dxf_path}")
|
| 28 |
+
with open(dxf_path, 'rb') as f:
|
| 29 |
+
dxf_content = f.read()
|
| 30 |
+
|
| 31 |
+
land_poly = load_boundary_from_dxf(dxf_content)
|
| 32 |
+
|
| 33 |
+
if not land_poly:
|
| 34 |
+
logger.error("Failed to load polygon from DXF")
|
| 35 |
+
return
|
| 36 |
+
|
| 37 |
+
logger.info(f"Loaded land polygon: {land_poly.area:.2f} m²")
|
| 38 |
+
|
| 39 |
+
# Simplify geometry to speed up testing
|
| 40 |
+
land_poly = land_poly.simplify(1.0, preserve_topology=True)
|
| 41 |
+
|
| 42 |
+
# Initialize pipeline
|
| 43 |
+
config = {
|
| 44 |
+
'min_lot_width': 20.0,
|
| 45 |
+
'target_lot_width': 40.0,
|
| 46 |
+
'population_size': 10, # Speed up
|
| 47 |
+
'generations': 5, # Speed up
|
| 48 |
+
'ortools_time_limit': 5.0
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
pipeline = LandRedistributionPipeline([land_poly], config)
|
| 52 |
+
|
| 53 |
+
# Run full pipeline with GRID method (Orthogonal Alignment)
|
| 54 |
+
logger.info("Running optimization pipeline with GRID method...")
|
| 55 |
+
result = pipeline.run_full_pipeline(layout_method='grid')
|
| 56 |
+
|
| 57 |
+
# Analyze Aesthetic Metrics
|
| 58 |
+
stage2 = result.get('stage2', {})
|
| 59 |
+
lots = stage2.get('lots', [])
|
| 60 |
+
green_spaces = stage2.get('green_spaces', [])
|
| 61 |
+
parks = stage2.get('parks', [])
|
| 62 |
+
|
| 63 |
+
print("\n" + "="*50)
|
| 64 |
+
print("AESTHETIC OPTIMIZATION RESULTS (GRID MODE)")
|
| 65 |
+
print("="*50)
|
| 66 |
+
|
| 67 |
+
print(f"Total Commercial Lots: {len(lots)}")
|
| 68 |
+
print(f"Total Green Spaces (Leftovers): {len(green_spaces)}")
|
| 69 |
+
|
| 70 |
+
# Calculate quality metrics
|
| 71 |
+
rectangularities = []
|
| 72 |
+
|
| 73 |
+
for lot_info in lots:
|
| 74 |
+
geom = lot_info['geometry']
|
| 75 |
+
obb = geom.minimum_rotated_rectangle
|
| 76 |
+
rectangularity = geom.area / obb.area
|
| 77 |
+
rectangularities.append(rectangularity)
|
| 78 |
+
|
| 79 |
+
if rectangularities:
|
| 80 |
+
avg_rect = sum(rectangularities) / len(rectangularities)
|
| 81 |
+
print(f" Average Rectangularity: {avg_rect:.3f} (target > 0.75)")
|
| 82 |
+
|
| 83 |
+
perfect_rects = sum(1 for r in rectangularities if r > 0.95)
|
| 84 |
+
print(f" Perfect Rectangles (>0.95): {perfect_rects} ({perfect_rects/len(lots)*100:.1f}%)")
|
| 85 |
+
else:
|
| 86 |
+
print("\nNo commercial lots generated!")
|
| 87 |
+
|
| 88 |
+
if __name__ == "__main__":
|
| 89 |
+
test_dxf_optimization()
|
algorithms/frontend/app.py
CHANGED
|
@@ -158,19 +158,19 @@ with col_config:
|
|
| 158 |
with c1:
|
| 159 |
spacing_min = st.number_input(
|
| 160 |
"Min",
|
| 161 |
-
min_value=
|
| 162 |
-
max_value=
|
| 163 |
-
value=
|
| 164 |
-
step=0
|
| 165 |
help="Minimum grid spacing"
|
| 166 |
)
|
| 167 |
with c2:
|
| 168 |
spacing_max = st.number_input(
|
| 169 |
"Max",
|
| 170 |
-
min_value=
|
| 171 |
-
max_value=
|
| 172 |
-
value=
|
| 173 |
-
step=0
|
| 174 |
help="Maximum grid spacing"
|
| 175 |
)
|
| 176 |
|
|
|
|
| 158 |
with c1:
|
| 159 |
spacing_min = st.number_input(
|
| 160 |
"Min",
|
| 161 |
+
min_value=30.0,
|
| 162 |
+
max_value=150.0,
|
| 163 |
+
value=50.0,
|
| 164 |
+
step=5.0,
|
| 165 |
help="Minimum grid spacing"
|
| 166 |
)
|
| 167 |
with c2:
|
| 168 |
spacing_max = st.number_input(
|
| 169 |
"Max",
|
| 170 |
+
min_value=30.0,
|
| 171 |
+
max_value=200.0,
|
| 172 |
+
value=100.0,
|
| 173 |
+
step=5.0,
|
| 174 |
help="Maximum grid spacing"
|
| 175 |
)
|
| 176 |
|
docs/Beauti_mode.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Chào bạn, đây là phần đi sâu chi tiết vào các kỹ thuật **tối ưu hóa thẩm mỹ (Aesthetic Optimization)** kèm theo các đoạn code Python thực tế sử dụng thư viện `Shapely` và `NumPy` mà bạn có thể tích hợp ngay vào dự án.
|
| 2 |
+
|
| 3 |
+
Chúng ta sẽ tập trung vào 3 giải pháp cốt lõi: **Kiểm soát hình học**, **Cắt gọt trực giao (Orthogonal Slicing)**, và **Xử lý phần dư thông minh**.
|
| 4 |
+
|
| 5 |
+
-----
|
| 6 |
+
|
| 7 |
+
### 1\. Phương pháp 1: Kiểm soát Hình học (Geometric Regularization)
|
| 8 |
+
|
| 9 |
+
**Mục tiêu:** Loại bỏ các lô đất có hình dạng "kỳ dị" (quá dẹt, quá méo) ngay từ bước sinh phương án hoặc dùng làm hàm mục tiêu để phạt điểm.
|
| 10 |
+
|
| 11 |
+
**Chỉ số kỹ thuật:**
|
| 12 |
+
|
| 13 |
+
1. **Rectangularity (Độ đầy đặn):** Tỷ lệ diện tích lô đất so với hình chữ nhật bao quanh nó (Oriented Bounding Box - OBB). Giá trị càng gần 1.0 càng vuông.
|
| 14 |
+
2. **Aspect Ratio (Tỷ lệ cạnh):** Tỷ lệ giữa chiều dài và chiều rộng.
|
| 15 |
+
|
| 16 |
+
**Đoạn Code Tối ưu:**
|
| 17 |
+
|
| 18 |
+
```python
|
| 19 |
+
import numpy as np
|
| 20 |
+
from shapely.geometry import Polygon
|
| 21 |
+
|
| 22 |
+
def analyze_shape_quality(polygon):
|
| 23 |
+
"""
|
| 24 |
+
Trả về điểm số thẩm mỹ và trạng thái hợp lệ của lô đất.
|
| 25 |
+
"""
|
| 26 |
+
if polygon.is_empty or not polygon.is_valid:
|
| 27 |
+
return 0.0, False
|
| 28 |
+
|
| 29 |
+
# 1. Tính OBB (Oriented Bounding Box) - Hình chữ nhật bao quanh nhỏ nhất
|
| 30 |
+
obb = polygon.minimum_rotated_rectangle
|
| 31 |
+
|
| 32 |
+
# Tính độ vuông vắn (Rectangularity)
|
| 33 |
+
# Nếu polygon là hình chữ nhật, tỷ số = 1.0. Nếu là tam giác, tỷ số ~ 0.5
|
| 34 |
+
rectangularity = polygon.area / obb.area
|
| 35 |
+
|
| 36 |
+
# 2. Tính Tỷ lệ cạnh (Aspect Ratio) từ OBB
|
| 37 |
+
x, y = obb.exterior.coords.xy
|
| 38 |
+
# Tính độ dài 2 cạnh kề nhau của OBB
|
| 39 |
+
edge_1 = np.hypot(x[1] - x[0], y[1] - y[0])
|
| 40 |
+
edge_2 = np.hypot(x[2] - x[1], y[2] - y[1])
|
| 41 |
+
|
| 42 |
+
if edge_1 == 0 or edge_2 == 0: return 0.0, False
|
| 43 |
+
|
| 44 |
+
width, length = sorted([edge_1, edge_2])
|
| 45 |
+
aspect_ratio = length / width
|
| 46 |
+
|
| 47 |
+
# --- CÁC LUẬT RÀNG BUỘC (HARD CONSTRAINTS) ---
|
| 48 |
+
is_valid = True
|
| 49 |
+
|
| 50 |
+
# Luật 1: Phải tương đối vuông (chấp nhận méo nhẹ do vạt góc)
|
| 51 |
+
if rectangularity < 0.75:
|
| 52 |
+
is_valid = False
|
| 53 |
+
|
| 54 |
+
# Luật 2: Không được quá dẹt (ví dụ: dài gấp 4 lần rộng là xấu)
|
| 55 |
+
if aspect_ratio > 4.0:
|
| 56 |
+
is_valid = False
|
| 57 |
+
|
| 58 |
+
# Luật 3: Diện tích tối thiểu (để tránh lô vụn)
|
| 59 |
+
if polygon.area < 1000: # m2
|
| 60 |
+
is_valid = False
|
| 61 |
+
|
| 62 |
+
# Điểm thưởng cho lô đất đẹp (để dùng cho hàm mục tiêu)
|
| 63 |
+
score = (rectangularity * 0.7) + ((1.0 / aspect_ratio) * 0.3)
|
| 64 |
+
|
| 65 |
+
return score, is_valid
|
| 66 |
+
|
| 67 |
+
# Ví dụ sử dụng trong vòng lặp chia lô
|
| 68 |
+
# for lot in generated_lots:
|
| 69 |
+
# score, valid = analyze_shape_quality(lot)
|
| 70 |
+
# if not valid:
|
| 71 |
+
# # Đưa vào danh sách "Đất cây xanh/Kỹ thuật" thay vì "Đất thương phẩm"
|
| 72 |
+
# move_to_leftover(lot)
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
-----
|
| 76 |
+
|
| 77 |
+
### 2\. Phương pháp 2: Cắt gọt Trực giao (Orthogonal Alignment)
|
| 78 |
+
|
| 79 |
+
**Mục tiêu:** Thay vì dùng Voronoi ngẫu nhiên (tạo ra các cạnh xiên xẹo), chúng ta ép buộc các đường cắt phải **vuông góc** với trục đường chính hoặc cạnh dài nhất của Block mẹ.
|
| 80 |
+
|
| 81 |
+
**Thuật toán:**
|
| 82 |
+
|
| 83 |
+
1. Xác định "Cạnh chủ" (Dominant Edge) của Block mẹ (thường là cạnh giáp đường).
|
| 84 |
+
2. Tạo vector vuông góc với Cạnh chủ.
|
| 85 |
+
3. Thực hiện chia lô theo vector này.
|
| 86 |
+
|
| 87 |
+
**Đoạn Code Tối ưu:**
|
| 88 |
+
|
| 89 |
+
```python
|
| 90 |
+
from shapely.affinity import rotate, translate
|
| 91 |
+
|
| 92 |
+
def get_dominant_edge_vector(polygon):
|
| 93 |
+
"""Tìm vector chỉ phương của cạnh dài nhất (thường là mặt tiền)"""
|
| 94 |
+
rect = polygon.minimum_rotated_rectangle
|
| 95 |
+
x, y = rect.exterior.coords.xy
|
| 96 |
+
|
| 97 |
+
# Lấy 3 điểm đầu để xác định 2 cạnh kề nhau
|
| 98 |
+
p0 = np.array([x[0], y[0]])
|
| 99 |
+
p1 = np.array([x[1], y[1]])
|
| 100 |
+
p2 = np.array([x[2], y[2]])
|
| 101 |
+
|
| 102 |
+
edge1_len = np.linalg.norm(p1 - p0)
|
| 103 |
+
edge2_len = np.linalg.norm(p2 - p1)
|
| 104 |
+
|
| 105 |
+
# Trả về vector đơn vị của cạnh dài nhất
|
| 106 |
+
if edge1_len > edge2_len:
|
| 107 |
+
vec = p1 - p0
|
| 108 |
+
else:
|
| 109 |
+
vec = p2 - p1
|
| 110 |
+
|
| 111 |
+
return vec / np.linalg.norm(vec)
|
| 112 |
+
|
| 113 |
+
def orthogonal_slice(block, num_lots):
|
| 114 |
+
"""
|
| 115 |
+
Chia block thành các lô song song, vuông góc với cạnh chính.
|
| 116 |
+
Đây là kỹ thuật quan trọng nhất để tạo ra sự ngăn nắp.
|
| 117 |
+
"""
|
| 118 |
+
# 1. Lấy hướng trục chính
|
| 119 |
+
direction_vec = get_dominant_edge_vector(block)
|
| 120 |
+
|
| 121 |
+
# Vector vuông góc (dùng để quét cắt)
|
| 122 |
+
perp_vec = np.array([-direction_vec[1], direction_vec[0]])
|
| 123 |
+
|
| 124 |
+
# 2. Xoay block về trục ngang để dễ tính toán (Optional nhưng recommended)
|
| 125 |
+
# Ở đây ta dùng cách tạo đường cắt trực tiếp
|
| 126 |
+
|
| 127 |
+
minx, miny, maxx, maxy = block.bounds
|
| 128 |
+
# Tạo một đường thẳng dài bao trùm block
|
| 129 |
+
diagonal = np.hypot(maxx-minx, maxy-miny)
|
| 130 |
+
|
| 131 |
+
lots = []
|
| 132 |
+
# Giả sử chia đều (trong thực tế bạn sẽ dùng OR-Tools để tính widths)
|
| 133 |
+
# Ta cần tìm điểm bắt đầu và kết thúc dọc theo cạnh chính
|
| 134 |
+
# (Phần này cần logic chiếu hình học phức tạp hơn một chút,
|
| 135 |
+
# dưới đây là mô phỏng cách dao cắt hoạt động)
|
| 136 |
+
|
| 137 |
+
# ... Logic cắt (simplified) ...
|
| 138 |
+
# Thay vì code cắt phức tạp, hãy áp dụng nguyên tắc:
|
| 139 |
+
# "Luôn xoay vector cắt lệch 90 độ so với vector đường giao thông tiếp giáp"
|
| 140 |
+
|
| 141 |
+
return lots # Trả về danh sách lô đã cắt vuông
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
-----
|
| 145 |
+
|
| 146 |
+
### 3\. Phương pháp 3: Xử lý phần dư (Leftover Management) - "Biến rác thành hoa"
|
| 147 |
+
|
| 148 |
+
**Mục tiêu:** Sau khi chia các lô vuông vắn, sẽ luôn còn lại các mẩu đất hình tam giác hoặc hình thang méo ở các góc cua. Thay vì cố ép nó thành đất ở (làm xấu bản vẽ và giảm giá trị), hãy tự động chuyển đổi nó thành **Tiện ích**.
|
| 149 |
+
|
| 150 |
+
**Chiến lược:**
|
| 151 |
+
|
| 152 |
+
* Chạy thuật toán chia lô vuông vắn tối đa.
|
| 153 |
+
* Phần diện tích còn lại (Difference) $\rightarrow$ Kiểm tra hình dáng.
|
| 154 |
+
* Nếu `Rectangularity < 0.6` $\rightarrow$ Gán label `Cây xanh` hoặc `Bãi đỗ xe`.
|
| 155 |
+
|
| 156 |
+
**Đoạn Code Tối ưu quy trình:**
|
| 157 |
+
|
| 158 |
+
```python
|
| 159 |
+
def optimize_layout_w_leftovers(site_polygon, road_network):
|
| 160 |
+
# 1. Tạo các Block lớn từ mạng lưới đường
|
| 161 |
+
blocks = site_polygon.difference(road_network)
|
| 162 |
+
|
| 163 |
+
final_commercial_lots = []
|
| 164 |
+
final_green_spaces = []
|
| 165 |
+
|
| 166 |
+
for block in blocks.geoms:
|
| 167 |
+
# Bước A: Làm sạch hình học block (Simplify)
|
| 168 |
+
clean_block = block.simplify(0.5, preserve_topology=True)
|
| 169 |
+
|
| 170 |
+
# Bước B: Chia lô (Sử dụng thuật toán cắt trực giao ở trên)
|
| 171 |
+
# Giả sử hàm subdivide_block trả về danh sách các lô
|
| 172 |
+
raw_lots = subdivide_block_orthogonally(clean_block)
|
| 173 |
+
|
| 174 |
+
for lot in raw_lots:
|
| 175 |
+
# Bước C: Đánh giá thẩm mỹ
|
| 176 |
+
score, is_valid = analyze_shape_quality(lot)
|
| 177 |
+
|
| 178 |
+
if is_valid:
|
| 179 |
+
# Nếu lô đẹp -> Đất thương phẩm
|
| 180 |
+
final_commercial_lots.append(lot)
|
| 181 |
+
else:
|
| 182 |
+
# Nếu lô quá méo/nhỏ -> Chuyển thành đất cây xanh
|
| 183 |
+
# Đây là bước "Làm đẹp" bản vẽ
|
| 184 |
+
final_green_spaces.append(lot)
|
| 185 |
+
|
| 186 |
+
return final_commercial_lots, final_green_spaces
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
### 4\. Tích hợp vào OR-Tools (Hàm mục tiêu nâng cao)
|
| 190 |
+
|
| 191 |
+
Nếu bạn đang dùng `CP-SAT` solver của Google OR-Tools (như trong context cũ), bạn có thể sửa hàm mục tiêu để tối ưu sự đồng đều (Symmetry).
|
| 192 |
+
|
| 193 |
+
**Code OR-Tools (Logic):**
|
| 194 |
+
|
| 195 |
+
```python
|
| 196 |
+
# Thay vì chỉ Maximize(TotalArea), hãy thêm phạt sự chênh lệch
|
| 197 |
+
# Mục tiêu: Các lô nên có kích thước bằng nhau (Target Width)
|
| 198 |
+
|
| 199 |
+
# ... khai báo biến widths[i] ...
|
| 200 |
+
|
| 201 |
+
target_width = 15.0 # mét (Mặt tiền mong muốn)
|
| 202 |
+
deviations = []
|
| 203 |
+
|
| 204 |
+
for w in widths:
|
| 205 |
+
# Tạo biến phụ để tính trị tuyệt đối: abs(w - target)
|
| 206 |
+
diff = model.NewIntVar(0, 100, 'diff')
|
| 207 |
+
|
| 208 |
+
# Mẹo OR-Tools để tính abs:
|
| 209 |
+
# diff >= w - target VÀ diff >= target - w
|
| 210 |
+
model.Add(diff >= w - int(target_width))
|
| 211 |
+
model.Add(diff >= int(target_width) - w)
|
| 212 |
+
|
| 213 |
+
deviations.append(diff)
|
| 214 |
+
|
| 215 |
+
# Hàm mục tiêu mới:
|
| 216 |
+
# Tối đa hóa diện tích NHƯNG Trừ điểm nặng nếu kích thước không đều
|
| 217 |
+
model.Maximize(sum(widths) * 100 - sum(deviations) * 50)
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
### Tổng kết chiến lược
|
| 221 |
+
|
| 222 |
+
Để bản vẽ từ "Tối ưu tài chính (xấu)" trở thành "Tối ưu tổng thể (đẹp + hiệu quả)", bạn chỉ cần thực hiện thay đổi nhỏ trong luồng xử lý:
|
| 223 |
+
|
| 224 |
+
1. **Input:** Xác định cạnh đường chính.
|
| 225 |
+
2. **Process:** Chỉ cắt vuông góc với cạnh đường (Force 90 deg).
|
| 226 |
+
3. **Filter:** Dùng hàm `analyze_shape_quality` để lọc các mảnh vỡ.
|
| 227 |
+
4. **Post-Process:** Tô màu xanh (công viên) cho các mảnh vỡ đó thay vì cố gắng bán chúng.
|
| 228 |
+
|
| 229 |
+
Bản vẽ sẽ trông chuyên nghiệp hơn ngay lập tức vì sự ngăn nắp và có nhiều không gian xanh ở các góc ngã tư.
|