AnonymousECCV15285 commited on
Commit
e266831
·
verified ·
1 Parent(s): 9dd4cdb

Upload 30 files

Browse files
.dockerignore ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+
11
+ # Virtual environments
12
+ venv/
13
+ env/
14
+ ENV/
15
+
16
+ # IDE
17
+ .vscode/
18
+ .idea/
19
+ *.swp
20
+ *.swo
21
+
22
+ # Git
23
+ .git/
24
+ .gitignore
25
+
26
+ # Output directories (will be mounted as volumes)
27
+ output/
28
+ temp_output/
29
+
30
+ # Documentation
31
+ *.md
32
+ README.md
33
+ DOCKER.md
34
+
35
+ # Docker files (don't need in image)
36
+ docker-compose.yml
37
+ Dockerfile
38
+ .dockerignore
39
+
40
+ # Large data files - NOTE: We NEED these for Blender to work
41
+ # *.blend
42
+ # data/shapes/
43
+ # data/materials/
44
+
45
+ # OS files
46
+ .DS_Store
47
+ Thumbs.db
.gitattributes CHANGED
@@ -1,35 +1,37 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.blend filter=lfs diff=lfs merge=lfs -text
37
+ scripts/__pycache__/generate_questions_mapping.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ venv/
9
+ ENV/
10
+ build/
11
+ develop-eggs/
12
+ dist/
13
+ downloads/
14
+ eggs/
15
+ .eggs/
16
+ lib/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ *.egg-info/
23
+ .installed.cfg
24
+ *.egg
25
+
26
+
27
+ # Output directory (generated scenes and images)
28
+ output/
29
+
30
+ # Blender temp files
31
+ temp_output/
32
+ temp_scenes/
33
+ render_images_patched.py
34
+ render_patched.py
35
+ *.blend1
36
+
37
+ # IDE
38
+ .vscode/
39
+ .idea/
40
+ *.swp
41
+ *.swo
42
+ *~
43
+
44
+ # OS
45
+ .DS_Store
46
+ Thumbs.db
DOCKER.md ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker Setup for Counterfactual Image Generator
2
+
3
+ This project includes Docker support for easy deployment and consistent environments.
4
+
5
+ **Windows Note:** This Dockerfile uses a Linux container (Ubuntu), which works perfectly on Windows. Docker Desktop on Windows runs Linux containers by default. The containerized environment is Linux, so Windows-specific code paths in the application won't apply inside the container.
6
+
7
+ ## Building the Docker Image
8
+
9
+ ```bash
10
+ docker build -t counterfactual-generator .
11
+ ```
12
+
13
+ ## Running with Docker
14
+
15
+ ### Basic Usage
16
+
17
+ **Linux/macOS:**
18
+ ```bash
19
+ # Run with default command (shows help)
20
+ docker run --rm counterfactual-generator
21
+
22
+ # Generate 10 scenes with 5 objects each
23
+ docker run --rm -v $(pwd)/output:/app/output counterfactual-generator \
24
+ python3 pipeline.py --num_scenes 10 --num_objects 5 --run_name docker_test
25
+
26
+ # With GPU support (requires nvidia-docker)
27
+ docker run --rm --gpus all -v $(pwd)/output:/app/output counterfactual-generator \
28
+ python3 pipeline.py --num_scenes 10 --num_objects 5 --use_gpu 1
29
+ ```
30
+
31
+ **Windows (PowerShell):**
32
+ ```powershell
33
+ # Run with default command (shows help)
34
+ docker run --rm counterfactual-generator
35
+
36
+ # Generate 10 scenes with 5 objects each
37
+ docker run --rm -v ${PWD}/output:/app/output counterfactual-generator `
38
+ python3 pipeline.py --num_scenes 10 --num_objects 5 --run_name docker_test
39
+
40
+ # With GPU support (requires WSL2 and nvidia-docker)
41
+ docker run --rm --gpus all -v ${PWD}/output:/app/output counterfactual-generator `
42
+ python3 pipeline.py --num_scenes 10 --num_objects 5 --use_gpu 1
43
+ ```
44
+
45
+ **Windows (Command Prompt):**
46
+ ```cmd
47
+ docker run --rm -v "%CD%/output:/app/output" counterfactual-generator ^
48
+ python3 pipeline.py --num_scenes 10 --num_objects 5 --run_name docker_test
49
+ ```
50
+
51
+ ### Using Docker Compose
52
+
53
+ ```bash
54
+ # Build and run with docker-compose
55
+ docker-compose up
56
+
57
+ # Run in detached mode
58
+ docker-compose up -d
59
+
60
+ # View logs
61
+ docker-compose logs -f
62
+
63
+ # Stop the container
64
+ docker-compose down
65
+ ```
66
+
67
+ ### Customize the Command
68
+
69
+ Edit `docker-compose.yml` to change the default command:
70
+
71
+ ```yaml
72
+ command: python3 pipeline.py --num_scenes 10 --num_objects 5 --use_gpu 1 --run_name my_experiment
73
+ ```
74
+
75
+ ## Volume Mounting
76
+
77
+ The output directory is mounted to `./output` on your host machine, so generated scenes and images will persist after the container stops.
78
+
79
+ ## GPU Support
80
+
81
+ For GPU rendering support, ensure you have:
82
+ 1. NVIDIA Docker runtime installed
83
+ 2. Use `--gpus all` flag with `docker run` or add to `docker-compose.yml`:
84
+
85
+ ```yaml
86
+ deploy:
87
+ resources:
88
+ reservations:
89
+ devices:
90
+ - driver: nvidia
91
+ count: all
92
+ capabilities: [gpu]
93
+ ```
94
+
95
+ ## Windows-Specific Notes
96
+
97
+ 1. **Docker Desktop Required**: Install Docker Desktop for Windows from [docker.com](https://www.docker.com/products/docker-desktop)
98
+
99
+ 2. **Path Format**: Use forward slashes or PowerShell variables:
100
+ - PowerShell: `-v ${PWD}/output:/app/output`
101
+ - CMD: `-v "%CD%/output:/app/output"`
102
+ - Or use absolute paths: `-v C:/path/to/output:/app/output`
103
+
104
+ 3. **WSL2**: Docker Desktop on Windows uses WSL2. Make sure WSL2 is enabled in Windows features.
105
+
106
+ 4. **GPU Support on Windows**: Requires:
107
+ - WSL2 with NVIDIA GPU driver
108
+ - nvidia-docker installed in WSL2
109
+ - May not work on all Windows setups
110
+
111
+ ## Troubleshooting
112
+
113
+ ### Blender Not Found
114
+ If you get errors about Blender not being found, verify the installation:
115
+ ```bash
116
+ # Linux/macOS
117
+ docker run --rm counterfactual-generator blender --version
118
+
119
+ # Windows PowerShell
120
+ docker run --rm counterfactual-generator blender --version
121
+ ```
122
+
123
+ ### Permission Issues
124
+ **Linux/macOS:**
125
+ ```bash
126
+ sudo chown -R $USER:$USER output/
127
+ ```
128
+
129
+ **Windows:** Usually not an issue, but if you encounter permission errors, run Docker Desktop as Administrator or adjust folder permissions.
130
+
131
+ ### Interactive Shell
132
+ **Linux/macOS:**
133
+ ```bash
134
+ docker run --rm -it -v $(pwd)/output:/app/output counterfactual-generator /bin/bash
135
+ ```
136
+
137
+ **Windows (PowerShell):**
138
+ ```powershell
139
+ docker run --rm -it -v ${PWD}/output:/app/output counterfactual-generator /bin/bash
140
+ ```
141
+
142
+ ## Notes
143
+
144
+ - The image includes Blender 4.3, which is compatible with the codebase
145
+ - Credentials and tokens are excluded from the Docker image for security
146
+ - Output files are stored in the mounted volume to persist data
147
+
Dockerfile ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.9 slim image for smaller size
2
+ FROM python:3.9-slim
3
+
4
+ # 1. Install system dependencies including Xvfb for headless rendering
5
+ # We install these as root before switching users
6
+ RUN apt-get update && apt-get install -y \
7
+ wget \
8
+ xz-utils \
9
+ xvfb \
10
+ libx11-6 \
11
+ libxxf86vm1 \
12
+ libgl1 \
13
+ libglu1-mesa \
14
+ libxi6 \
15
+ libxrender1 \
16
+ libxfixes3 \
17
+ libfontconfig1 \
18
+ libxinerama1 \
19
+ libxkbcommon0 \
20
+ && rm -rf /var/lib/apt/lists/*
21
+
22
+ # 2. Set up a non-root user (Critical for Hugging Face Spaces)
23
+ # Spaces run as user ID 1000. If we don't do this, we get permission errors.
24
+ RUN useradd -m -u 1000 user
25
+ USER user
26
+ ENV HOME=/home/user \
27
+ PATH=/home/user/.local/bin:$PATH
28
+
29
+ # Set working directory to the new user's home
30
+ WORKDIR $HOME/app
31
+
32
+ # 3. Install Blender (User-level install)
33
+ # Downloading to a folder the user owns
34
+ RUN wget -q https://download.blender.org/release/Blender3.6/blender-3.6.8-linux-x64.tar.xz -O /tmp/blender.tar.xz && \
35
+ tar -xf /tmp/blender.tar.xz -C /tmp && \
36
+ mkdir -p $HOME/blender && \
37
+ mv /tmp/blender-3.6.8-linux-x64/* $HOME/blender/ && \
38
+ rm -rf /tmp/blender.tar.xz /tmp/blender-3.6.8-linux-x64 && \
39
+ # Add Blender to PATH so we can just type 'blender'
40
+ echo 'export PATH="$HOME/blender:$PATH"' >> $HOME/.bashrc
41
+
42
+ # Update PATH for this session as well
43
+ ENV PATH="$HOME/blender:$PATH"
44
+
45
+ # Verify Blender installation
46
+ RUN blender --version
47
+
48
+ # 4. Install Python dependencies
49
+ COPY --chown=user requirements.txt .
50
+ RUN pip install --no-cache-dir -r requirements.txt
51
+
52
+ # 5. Copy application code with correct ownership
53
+ COPY --chown=user . .
54
+
55
+ # Create output directories that the user can write to
56
+ RUN mkdir -p $HOME/app/output $HOME/app/temp_output
57
+
58
+ # 6. Configuration for Hugging Face Spaces
59
+ # Spaces expect the app to listen on port 7860
60
+ ENV PORT=7860
61
+ EXPOSE 7860
62
+
63
+ # Healthcheck to ensure the container doesn't get killed
64
+ HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
65
+ CMD wget --no-verbose --tries=1 --spider http://localhost:7860/_stcore/health || exit 1
66
+
67
+ # Set up Xvfb for headless rendering
68
+ ENV DISPLAY=:99
69
+
70
+ # 7. Run Streamlit on port 7860
71
+ CMD ["sh", "-c", "xvfb-run -a -s '-screen 0 1024x768x24' streamlit run app.py --server.port=7860 --server.address=0.0.0.0 --server.headless=true"]
README.md CHANGED
@@ -1,12 +1,418 @@
1
- ---
2
- title: MMIB Counterfactual Image Generation Tool
3
- emoji: 🏢
4
- colorFrom: indigo
5
- colorTo: red
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- short_description: Counterfactual image generation tool
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: MMB Counterfactual Image Tool
3
+ colorFrom: blue
4
+ colorTo: indigo
5
+ sdk: docker
6
+ pinned: false
7
+ license: mit
8
+ arxiv: 2401.xxxxx
9
+ ---
10
+
11
+ A Python-based pipeline for CLEVR-style scenes with counterfactual variants and Blender rendering. Configurable object counts, counterfactual types, and automated questions and answers.
12
+
13
+ **Key Features:**
14
+ - 17 different counterfactual types (9 image CFs + 8 negative CFs)
15
+ - Automated Blender rendering with GPU support
16
+ - Question-answer datasets
17
+ - Resume support for interrupted jobs
18
+ - Streamlit web interface
19
+ - Examples for all counterfactual types
20
+
21
+ ## Hugging Face Spaces Deployment
22
+
23
+ This app is configured to run on Hugging Face Spaces using Docker. The Dockerfile includes Blender installation and all necessary dependencies for scenes and rendering.
24
+
25
+ ## Features
26
+
27
+ - **Scenes**: Multiple scene sets with configurable object counts and properties
28
+ - **Counterfactual Variants**: Create multiple counterfactual variations per scene (color, shape, material, size changes)
29
+ - **Blender Rendering**: Automated rendering of scenes to PNG images using Blender's Cycles engine
30
+ - **Questions**: Counterfactual questions and answers for each scene set
31
+ - **Resume Support**: Resume interrupted rendering jobs
32
+
33
+ ## Requirements
34
+
35
+ ### Python Dependencies
36
+
37
+ Install Python dependencies using:
38
+
39
+ ```bash
40
+ pip install -r requirements.txt
41
+ ```
42
+
43
+ ### Blender
44
+
45
+ Blender must be installed separately as it's not available via pip. The scripts are designed to run inside Blender's Python environment using:
46
+
47
+ ```bash
48
+ blender --background --python <script.py> -- [arguments]
49
+ ```
50
+
51
+ The pipeline will attempt to auto-detect Blender on your system, or you can specify the path manually.
52
+
53
+ ## Installation
54
+
55
+ 1. Clone this repository:
56
+ ```bash
57
+ git clone <repository-url>
58
+ cd code
59
+ ```
60
+
61
+ 2. Install Python dependencies:
62
+ ```bash
63
+ pip install -r requirements.txt
64
+ ```
65
+
66
+ 3. Ensure Blender is installed and accessible in your PATH, or note the path for manual specification.
67
+
68
+ ## Usage
69
+
70
+ ### Quick Start
71
+
72
+ ```bash
73
+ # Install dependencies
74
+ pip install -r requirements.txt
75
+
76
+ # Generate 5 scene sets with 5 objects each
77
+ python pipeline.py --num_scenes 5 --num_objects 5 --run_name my_first_run
78
+
79
+ # Generate examples of all counterfactual types
80
+ python scripts/generate_examples.py --render
81
+ ```
82
+
83
+ ### Web Interface (Streamlit)
84
+
85
+ Run the Streamlit web interface for an interactive experience:
86
+
87
+ ```bash
88
+ streamlit run app.py
89
+ ```
90
+
91
+ The web interface provides:
92
+ - Interactive parameter configuration
93
+ - Real-time progress
94
+ - Visual output preview
95
+ - Downloadable results as ZIP files
96
+
97
+ ### 1. Generate and Render (Combined)
98
+
99
+ Use `python pipeline.py` to generate scene JSON files and render PNG images in one step:
100
+
101
+ ```bash
102
+ python pipeline.py --num_scenes 10 --num_objects 5 --run_name experiment1
103
+ ```
104
+
105
+ ### 2. Generate Only (Skip Rendering)
106
+
107
+ Generate scenes without rendering:
108
+
109
+ ```bash
110
+ python pipeline.py --num_scenes 10 --num_objects 5 --run_name experiment1 --skip_render
111
+ ```
112
+
113
+ ### 4. Render Existing Scenes Only
114
+
115
+ Render previously generated scene JSON files:
116
+
117
+ ```bash
118
+ # Render scenes from a specific run
119
+ python pipeline.py --render_only --run_name experiment1
120
+
121
+ # Auto-detect latest run
122
+ python pipeline.py --render_only --auto_latest
123
+
124
+ # Use GPU rendering
125
+ python pipeline.py --render_only --run_name experiment1 --use_gpu 1
126
+ ```
127
+
128
+ This allows you to:
129
+ - Generate scenes on one machine, render on another
130
+ - Generate many scenes, then render them in batches
131
+ - Re-render scenes with different settings (GPU/CPU, samples, resolution) without regenerating
132
+
133
+ ### 5. Generating Scenes and Images (Full Options)
134
+
135
+ #### Arguments
136
+
137
+ | Argument | Default | Description |
138
+ | ------------------------- | ------------- | ---------------------------------------------------------------------------- |
139
+ | `--num_scenes` | 5 | Number of scene sets to generate |
140
+ | `--num_objects` | None | Fixed number of objects per scene (overrides min/max) |
141
+ | `--min_objects` | 3 | Minimum object count (if `num_objects` not given) |
142
+ | `--max_objects` | 7 | Maximum object count (if `num_objects` not given) |
143
+ | `--num_counterfactuals` | 2 | Number of counterfactual variants per scene |
144
+ | `--blender_path` | auto-detected | Path to Blender executable |
145
+ | `--output_dir` | `output` | Base directory for all runs (each run gets a timestamped/named subfolder) |
146
+ | `--run_name` | None | Optional custom name for this run (creates `output/run_name/`) |
147
+ | `--use_gpu` | 0 | 1 = enable GPU rendering, 0 = CPU rendering |
148
+ | `--samples` | 512 | Cycles sampling rate (higher = better quality, slower) |
149
+ | `--width` | 320 | Image width in pixels |
150
+ | `--height` | 240 | Image height in pixels |
151
+ | `--skip_render` | False | Generate scenes only (JSON only, no PNG rendering) |
152
+ | `--render_only` | False | Only render existing scene JSON files. Requires --run_dir, --run_name, or --auto_latest |
153
+ | `--run_dir` | None | Run directory containing scenes/ folder (for --render_only mode) |
154
+ | `--auto_latest` | False | Automatically use the latest run in output_dir (for --render_only mode) |
155
+ | `--resume` | False | Resume from where it stopped in an existing run |
156
+ | `--cf_types` | None | Explicitly choose which counterfactual types are used (order is respected; cycles if needed) |
157
+ | `--same_cf_type` | False | Use the same counterfactual type for all variants (first in `--cf_types`, or one random if unset) |
158
+ | `--list_cf_types` | False | Print available counterfactual types and exit |
159
+ | `--min_cf_change_score` | 1.0 | Minimum heuristic change score (retries CF until it’s “noticeable” enough) |
160
+ | `--max_cf_attempts` | 10 | Max retries per counterfactual to reach `--min_cf_change_score` |
161
+
162
+ #### Examples
163
+
164
+ ```bash
165
+ # Generate 10 scene sets with 5 objects each
166
+ python3 pipeline.py --num_scenes 10 --num_objects 5 --run_name experiment1
167
+
168
+ # Generate 10 scene sets with 3-7 objects, using GPU
169
+ python3 pipeline.py --num_scenes 10 --min_objects 3 --max_objects 7 --use_gpu 1
170
+
171
+ # Resume from where it stopped
172
+ python3 pipeline.py --num_scenes 100 --run_name experiment1 --resume
173
+
174
+ # Generate scenes only (no rendering)
175
+ python3 pipeline.py --num_scenes 10 --skip_render
176
+
177
+ # List available counterfactual types
178
+ python3 pipeline.py --list_cf_types
179
+
180
+ # Force specific counterfactual types in order (CF1 then CF2)
181
+ python3 pipeline.py --num_scenes 10 --num_counterfactuals 2 --cf_types change_color add_noise
182
+ # All counterfactuals same type (e.g. both change_color):
183
+ python3 pipeline.py --num_scenes 10 --num_counterfactuals 2 --cf_types change_color --same_cf_type
184
+
185
+ # Make counterfactuals more noticeable (stricter threshold + more retries)
186
+ python3 pipeline.py --num_scenes 10 --min_cf_change_score 1.5 --max_cf_attempts 25
187
+ ```
188
+
189
+ #### Counterfactual Types
190
+
191
+ The pipeline supports 17 different counterfactual types, divided into two categories:
192
+
193
+ **Image Counterfactuals** (Should change VQA answers - 10 types):
194
+ - `change_color` - Change the color of a random object (e.g., red → blue)
195
+ - `change_shape` - Change the shape of a random object (cube/sphere/cylinder)
196
+ - `change_size` - Change the size of a random object (small ↔ large)
197
+ - `change_material` - Change the material of a random object (metal ↔ rubber)
198
+ - `change_position` - Move a random object to a different location (with collision detection)
199
+ - `add_object` - Add a new random object to the scene
200
+ - `remove_object` - Remove a random object from the scene
201
+ - `replace_object` - Replace an object with a different one (keeping position)
202
+ - `swap_attribute` - Swap an attribute (e.g. color) between two objects
203
+ - `relational_flip` - Move object from left of X to right of X
204
+
205
+ **Negative Counterfactuals** (Should NOT change VQA answers - 8 types):
206
+ - `change_background` - Change the background/ground color
207
+ - `change_lighting` - Change lighting conditions (bright/dim/warm/cool/dramatic)
208
+ - `add_noise` - Add image noise/grain (light/medium/heavy levels)
209
+ - `occlusion_change` - Move object to partially hide another (visual only; answers unchanged)
210
+ - `apply_fisheye` - Apply fisheye lens distortion effect
211
+ - `apply_blur` - Apply Gaussian blur filter
212
+ - `apply_vignette` - Apply vignette effect (edge darkening)
213
+ - `apply_chromatic_aberration` - Apply chromatic aberration (color fringing)
214
+
215
+ **Default Behavior**: If `--cf_types` is not specified, the pipeline uses a default mix of 1 image counterfactual + 1 negative counterfactual per scene.
216
+
217
+ **Usage Examples**:
218
+ ```bash
219
+ # List all available counterfactual types
220
+ python3 pipeline.py --list_cf_types
221
+
222
+ # Use specific counterfactual types (order is respected; cycles if needed)
223
+ python3 pipeline.py --num_scenes 10 --num_counterfactuals 2 --cf_types change_color change_position
224
+
225
+ # Mix image and negative counterfactuals
226
+ python3 pipeline.py --num_scenes 10 --cf_types change_shape change_lighting add_noise
227
+
228
+ # Use only negative counterfactuals
229
+ python3 pipeline.py --num_scenes 10 --cf_types change_background add_noise apply_blur
230
+
231
+ # Use only image counterfactuals
232
+ python3 pipeline.py --num_scenes 10 --cf_types change_color change_shape change_position
233
+ ```
234
+
235
+ ### 2. Generating Example Counterfactuals
236
+
237
+ Generate examples of all counterfactual types applied to a base scene:
238
+
239
+ ```bash
240
+ # Generate scene JSON files only
241
+ python scripts/generate_examples.py
242
+
243
+ # Generate and render to images
244
+ python scripts/generate_examples.py --render
245
+
246
+ # Customize output directory and number of objects
247
+ python scripts/generate_examples.py --render --output_dir output/my_examples --num_objects 7
248
+
249
+ # Use GPU rendering
250
+ python scripts/generate_examples.py --render --use_gpu 1
251
+ ```
252
+
253
+ This creates one example of each counterfactual type in `output/counterfactual_examples/`:
254
+ - Original scene
255
+ - 9 image counterfactual examples
256
+ - 8 negative counterfactual examples
257
+
258
+ ### 3. Generating Question Mappings
259
+
260
+ Use `scripts/generate_questions_mapping.py` to create a CSV with counterfactual questions and answers.
261
+
262
+ #### Arguments
263
+
264
+ | Argument | Default | Description |
265
+ | ---------------------- | ---------------------------------- | -------------------------------------------------------------------------------- |
266
+ | `--output_dir` | `output` | Run directory or base output directory |
267
+ | `--auto_latest` | False | Automatically find and use the latest run in `output_dir` |
268
+ | `--csv_name` | `image_mapping_with_questions.csv` | Name of the generated CSV file |
269
+ | `--generate_questions` | False | Generate questions and answers (without this flag, only outputs image filenames) |
270
+ | `--long_format` | False | Also output a long-format QA dataset CSV (one row per image-question pair) |
271
+ | `--long_csv_name` | `qa_dataset.csv` | Filename for the long-format QA dataset CSV |
272
+
273
+ #### Examples
274
+
275
+ ```bash
276
+ # Generate questions for a specific run
277
+ python scripts/generate_questions_mapping.py --output_dir output/experiment1 --generate_questions
278
+
279
+ # Auto-detect latest run and generate questions
280
+ python scripts/generate_questions_mapping.py --output_dir output --auto_latest --generate_questions
281
+
282
+ # Also generate a long-format QA dataset (includes correct answer labels/ids)
283
+ python scripts/generate_questions_mapping.py --output_dir output --auto_latest --generate_questions --long_format --long_csv_name qa_dataset.csv
284
+ ```
285
+
286
+ #### Ensuring counterfactual answers differ
287
+
288
+ For evaluation and datasets, the **counterfactual image’s answer to its counterfactual question** should differ from the **original image’s answer to the original question**. The pipeline does this in two ways:
289
+
290
+ 1. **Automatic retries**
291
+ When generating questions (with `--generate_questions`), the script retries up to **`MAX_CF_ANSWER_RETRIES`** (default 50) per scene. For each attempt it picks new counterfactual questions (with different randomness). It keeps a pair only when both CF1 and CF2 answers differ from the original answer (after normalizing, e.g. case and whitespace). If after all retries they still match, the scene is still included and a warning is printed.
292
+
293
+ 2. **CF-specific question templates**
294
+ Counterfactual questions are chosen from templates that target the **changed** attribute or count, so the answer on the counterfactual image is different by design:
295
+ - **Count-changing CFs** (e.g. `add_object`, `remove_object`): questions like “How many objects are in the scene?” or “Are there more than N objects?” so the count/yes-no differs.
296
+ - **Attribute-changing CFs** (e.g. `change_color`, `change_shape`): questions about the **new** value (e.g. “How many red objects?” when an object was changed to red), so the count on the CF image differs from the original.
297
+
298
+ **What you need to do:**
299
+
300
+ - Run question generation **after** scenes and images exist (either as part of the pipeline with `--generate_questions`, or later on a run directory):
301
+
302
+ ```bash
303
+ # As part of a full run (ensures answers differ for that run’s scenes)
304
+ python pipeline.py --num_scenes 10 --num_objects 5 --run_name my_run --generate_questions
305
+
306
+ # Or later, on an existing run
307
+ python scripts/generate_questions_mapping.py --output_dir output/my_run --generate_questions
308
+ ```
309
+
310
+ - **Optional:** To allow more attempts per scene, edit `scripts/generate_questions_mapping.py` and increase **`MAX_CF_ANSWER_RETRIES`** (e.g. from 50 to 100). No CLI flag is exposed for this.
311
+
312
+ **720p dataset with different answers (distinct CF types per scene):**
313
+
314
+ - Scenes use **two distinct counterfactual types** per scene (no duplicate CF type for CF1 and CF2). To generate a 720p (1280×720) set with question/answer validation and optional removal of scenes where the CF answer matches the original:
315
+
316
+ ```bash
317
+ python pipeline.py --num_scenes 1000 --output_dir output --run_name dataset_720p_diff --width 1280 --height 720 --semantic_only --num_counterfactuals 2 --generate_questions --filter_same_answer
318
+ ```
319
+
320
+ - This produces `output/dataset_720p_diff/` with scene sets where CF1 and CF2 use different types, questions are validated (original + CFs), and rows where the CF image’s answer to the CF question matches the original are removed. Increase `MAX_CF_ANSWER_RETRIES` in `scripts/generate_questions_mapping.py` if you want fewer “could not find valid questions” warnings.
321
+
322
+
323
+ ## Project Structure
324
+
325
+ ```
326
+ .
327
+ ├── pipeline.py # Main pipeline and rendering (supports --render_only)
328
+ ├── app.py # Streamlit web interface
329
+ ├── requirements.txt # Python dependencies
330
+ ├── data/ # Scene assets
331
+ │ ├── base_scene.blend # Base Blender scene
332
+ │ ├── CoGenT_A.json # Scene configuration A
333
+ │ ├── CoGenT_B.json # Scene configuration B
334
+ │ ├── properties.json # Object properties configuration
335
+ │ ├── materials/ # Material definitions
336
+ │ └── shapes/ # 3D shape definitions
337
+ ├── scripts/ # Scripts directory
338
+ │ ├── render.py # Blender rendering script (run by pipeline)
339
+ │ ├── generate_scenes.py # Generate scene JSON files only (alternative to --skip_render)
340
+ │ ├── generate_examples.py # Generate example counterfactuals
341
+ │ └── generate_questions_mapping.py # Generate question-answer datasets
342
+ ├── output/ # Output directory (generated)
343
+ │ └── counterfactual_examples/ # Example counterfactuals (from generate_examples.py)
344
+ ├── .gitignore # Git ignore rules
345
+ └── README.md # This file
346
+
347
+ **Note**: The pipeline dynamically generates temporary files during execution:
348
+ - `render_images_patched.py` - Patched rendering script
349
+ - `temp_output/<run_id>/` - Temporary output per run (e.g. `temp_output/2025-01-29_12-30-45/images` and `.../scenes`), so different runs use separate directories and do not overwrite each other.
350
+
351
+ These files are automatically created when needed, cleaned up after use, and are ignored by git.
352
+ ```
353
+
354
+ ## Output Structure
355
+
356
+ Each run creates a directory structure like:
357
+
358
+ ```
359
+ output/
360
+ └── <run_name_or_timestamp>/
361
+ ├── checkpoint.json
362
+ ├── run_metadata.json
363
+ ├── scenes/
364
+ │ ├── scene_0000_original.json
365
+ │ ├── scene_0000_cf1.json
366
+ │ ├── scene_0000_cf2.json
367
+ │ └── ...
368
+ ├── images/
369
+ │ ├── scene_0000_original.png
370
+ │ ├── scene_0000_cf1.png
371
+ │ ├── scene_0000_cf2.png
372
+ │ └── ...
373
+ ├── image_mapping_with_questions*.csv
374
+ └── qa_dataset*.csv (if generated with --long_format)
375
+ ```
376
+
377
+ ### Dataset columns (high level)
378
+
379
+ **Wide Format CSV** (default with `--generate_questions`):
380
+ - **Image columns**: `original_image`, `counterfactual1_image`, `counterfactual2_image`
381
+ - **Question columns**: `original_question`, `counterfactual1_question`, `counterfactual2_question`
382
+ - **Answer matrix columns** (9 total, showing each image's answer to each question):
383
+ - `original_image_answer_to_original_question`
384
+ - `original_image_answer_to_cf1_question`
385
+ - `original_image_answer_to_cf2_question`
386
+ - `cf1_image_answer_to_original_question`
387
+ - `cf1_image_answer_to_cf1_question`
388
+ - `cf1_image_answer_to_cf2_question`
389
+ - `cf2_image_answer_to_original_question`
390
+ - `cf2_image_answer_to_cf1_question`
391
+ - `cf2_image_answer_to_cf2_question`
392
+
393
+ **Basic Format CSV** (without `--generate_questions`):
394
+ - Only image columns: `original_image`, `counterfactual1_image`, `counterfactual2_image`
395
+
396
+ ## Code Style
397
+
398
+ The codebase follows a clean, consistent style:
399
+ - No multiline docstrings (removed for brevity)
400
+ - Consistent spacing and formatting
401
+ - Single blank lines between functions
402
+ - Clear, concise function names
403
+
404
+ ## License
405
+
406
+ MIT License
407
+
408
+ ## Contributing
409
+
410
+ Contributions are welcome! Please ensure:
411
+ - Code follows the existing style (no multiline docstrings, consistent spacing)
412
+ - All tests pass
413
+ - Documentation is updated as needed
414
+
415
+ ## Acknowledgments
416
+
417
+ This project is inspired by the CLEVR dataset and uses Blender for 3D scene rendering.
418
+
analyze_semantic_failures.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ from pathlib import Path
3
+
4
+
5
+ def main() -> None:
6
+ base_dir = Path(__file__).resolve().parent
7
+
8
+ rel_path = base_dir / "output" / "200samples_mmib_rel" / "image_mapping_with_questions_200_rel.csv"
9
+ sem_path = base_dir / "output" / "200samples_mmib_rel" / "image_mapping_with_questions_200_semantic.csv"
10
+
11
+ with rel_path.open(newline="", encoding="utf-8") as f:
12
+ rel_reader = csv.DictReader(f)
13
+ rel_rows = list(rel_reader)
14
+
15
+ with sem_path.open(newline="", encoding="utf-8") as f:
16
+ sem_reader = csv.DictReader(f)
17
+ sem_rows = list(sem_reader)
18
+
19
+ # Keys identifying a CF pair
20
+ def key_from_rel(row: dict) -> tuple[str, str]:
21
+ return row["original_image"], row["counterfactual_image"]
22
+
23
+ def key_from_sem(row: dict) -> tuple[str, str]:
24
+ return row["original_image"], row["counterfactual_image"]
25
+
26
+ sem_keys = {key_from_sem(r) for r in sem_rows}
27
+
28
+ missing = [r for r in rel_rows if key_from_rel(r) not in sem_keys]
29
+
30
+ print(f"Total rel rows: {len(rel_rows)}")
31
+ print(f"Total semantic rows: {len(sem_rows)}")
32
+ print(f"Missing (failed) pairs: {len(missing)}\n")
33
+
34
+ for r in missing:
35
+ print(
36
+ f"{r['original_image']} -> {r['counterfactual_image']} | "
37
+ f"type={r.get('counterfactual_type','')} | desc={r.get('counterfactual_description','')}"
38
+ )
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()
43
+
app.py ADDED
@@ -0,0 +1,1058 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import os
3
+ import sys
4
+ import tempfile
5
+ import zipfile
6
+ import json
7
+ import random
8
+ import math
9
+ import csv
10
+ from pathlib import Path
11
+ from datetime import datetime
12
+ import time
13
+
14
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
15
+
16
+ script_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'scripts')
17
+ sys.path.insert(0, script_dir)
18
+
19
+ try:
20
+ from pipeline import (
21
+ generate_counterfactuals,
22
+ generate_base_scene,
23
+ save_scene,
24
+ render_scene,
25
+ create_patched_render_script,
26
+ IMAGE_COUNTERFACTUALS,
27
+ NEGATIVE_COUNTERFACTUALS
28
+ )
29
+ try:
30
+ import sys
31
+ script_dir = os.path.dirname(os.path.abspath(__file__))
32
+ scripts_path = os.path.join(script_dir, 'scripts')
33
+ if scripts_path not in sys.path:
34
+ sys.path.insert(0, scripts_path)
35
+ from generate_questions_mapping import (
36
+ load_scene,
37
+ generate_question_for_scene as _generate_question_for_scene_file,
38
+ answer_question_for_scene,
39
+ generate_mapping_with_questions
40
+ )
41
+ except ImportError:
42
+ def load_scene(scene_file):
43
+ with open(scene_file, 'r') as f:
44
+ return json.load(f)
45
+ def answer_question_for_scene(question, scene):
46
+ objects = scene.get('objects', [])
47
+ return len(objects)
48
+ _generate_question_for_scene_file = None
49
+ generate_mapping_with_questions = None
50
+ PIPELINE_AVAILABLE = True
51
+ except ImportError as e:
52
+ print(f"Warning: Error importing pipeline functions: {e}")
53
+ PIPELINE_AVAILABLE = False
54
+ answer_question_for_scene = None
55
+ load_scene = None
56
+ _generate_question_for_scene_file = None
57
+
58
+ st.set_page_config(
59
+ page_title="Counterfactual Image Generator",
60
+ page_icon="🎨",
61
+ layout="wide",
62
+ initial_sidebar_state="expanded"
63
+ )
64
+
65
+ st.markdown("""
66
+ <style>
67
+ .main-header {
68
+ font-size: 2.5rem;
69
+ font-weight: bold;
70
+ color: #1f77b4;
71
+ text-align: center;
72
+ margin-bottom: 2rem;
73
+ }
74
+ .stButton>button {
75
+ width: 100%;
76
+ height: 3.5rem;
77
+ font-size: 1.2rem;
78
+ font-weight: bold;
79
+ background-color: #1f77b4;
80
+ color: white;
81
+ border-radius: 0.5rem;
82
+ }
83
+ .stButton>button:hover {
84
+ background-color: #1565c0;
85
+ }
86
+ .info-box {
87
+ padding: 1rem;
88
+ border-radius: 0.5rem;
89
+ background-color: #f0f2f6;
90
+ margin: 1rem 0;
91
+ }
92
+ </style>
93
+ """, unsafe_allow_html=True)
94
+
95
+ def create_zip_file(output_dir, zip_path):
96
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
97
+ for root, dirs, files in os.walk(output_dir):
98
+ for file in files:
99
+ file_path = os.path.join(root, file)
100
+ arcname = os.path.relpath(file_path, output_dir)
101
+ zipf.write(file_path, arcname)
102
+
103
+
104
+ def generate_fallback_scene(num_objects, scene_idx):
105
+ script_dir = os.path.dirname(os.path.abspath(__file__))
106
+ props_path = os.path.join(script_dir, 'data', 'properties.json')
107
+
108
+ try:
109
+ with open(props_path, 'r') as f:
110
+ properties = json.load(f)
111
+ except:
112
+ properties = {
113
+ 'shapes': {'cube': 'SmoothCube_v2', 'sphere': 'Sphere', 'cylinder': 'SmoothCylinder'},
114
+ 'colors': {'gray': [87, 87, 87], 'red': [173, 35, 35], 'blue': [42, 75, 215],
115
+ 'green': [29, 105, 20], 'brown': [129, 74, 25], 'purple': [129, 38, 192],
116
+ 'cyan': [41, 208, 208], 'yellow': [255, 238, 51]},
117
+ 'materials': {'rubber': 'Rubber', 'metal': 'MyMetal'},
118
+ 'sizes': {'large': 0.7, 'small': 0.35}
119
+ }
120
+
121
+ shapes = list(properties['shapes'].keys())
122
+ colors = list(properties['colors'].keys())
123
+ materials = list(properties['materials'].keys())
124
+ sizes = list(properties['sizes'].keys())
125
+
126
+ scene_num = scene_idx + 1
127
+ scene = {
128
+ 'split': 'fallback',
129
+ 'image_index': scene_num,
130
+ 'image_filename': f'scene_{scene_num:04d}_original.png',
131
+ 'objects': [],
132
+ 'directions': {
133
+ 'behind': (0.0, -1.0, 0.0),
134
+ 'front': (0.0, 1.0, 0.0),
135
+ 'left': (-1.0, 0.0, 0.0),
136
+ 'right': (1.0, 0.0, 0.0),
137
+ 'above': (0.0, 0.0, 1.0),
138
+ 'below': (0.0, 0.0, -1.0)
139
+ }
140
+ }
141
+
142
+ positions = []
143
+ min_dist = 0.25
144
+
145
+ for i in range(num_objects):
146
+ max_attempts = 100
147
+ placed = False
148
+
149
+ for attempt in range(max_attempts):
150
+ x = random.uniform(-3, 3)
151
+ y = random.uniform(-3, 3)
152
+ z = random.uniform(0.35, 0.7)
153
+
154
+ collision = False
155
+ size = random.choice(sizes)
156
+ r = properties['sizes'][size]
157
+
158
+ for (px, py, pz, pr) in positions:
159
+ dist = math.sqrt((x - px)**2 + (y - py)**2)
160
+ if dist < (r + pr + min_dist):
161
+ collision = True
162
+ break
163
+
164
+ if not collision:
165
+ positions.append((x, y, z, r))
166
+ placed = True
167
+ break
168
+
169
+ if not placed:
170
+ x = random.uniform(-3, 3)
171
+ y = random.uniform(-3, 3)
172
+ z = random.uniform(0.35, 0.7)
173
+ size = random.choice(sizes)
174
+ r = properties['sizes'][size]
175
+ positions.append((x, y, z, r))
176
+
177
+ shape = random.choice(shapes)
178
+ color = random.choice(colors)
179
+ material = random.choice(materials)
180
+
181
+ obj = {
182
+ 'shape': shape,
183
+ 'size': size,
184
+ 'material': material,
185
+ '3d_coords': [x, y, z],
186
+ 'rotation': random.uniform(0, 360),
187
+ 'pixel_coords': [0, 0, 0],
188
+ 'color': color
189
+ }
190
+
191
+ scene['objects'].append(obj)
192
+
193
+ return scene
194
+
195
+
196
+ def generate_question_for_scene_dict(scene):
197
+ if _generate_question_for_scene_file is None:
198
+ objects = scene.get('objects', [])
199
+ if len(objects) == 0:
200
+ return "How many objects are in the scene?", {}
201
+
202
+ colors = list(set(obj.get('color') for obj in objects if obj.get('color')))
203
+ shapes = list(set(obj.get('shape') for obj in objects if obj.get('shape')))
204
+
205
+ if colors:
206
+ return f"How many {random.choice(colors)} objects are there?", {'color': random.choice(colors)}
207
+ else:
208
+ return "How many objects are in the scene?", {}
209
+
210
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp_file:
211
+ json.dump(scene, tmp_file)
212
+ tmp_path = tmp_file.name
213
+
214
+ try:
215
+ question, params = _generate_question_for_scene_file(tmp_path)
216
+ return question, params
217
+ finally:
218
+ try:
219
+ os.unlink(tmp_path)
220
+ except:
221
+ pass
222
+
223
+
224
+ def generate_counterfactual_scenes(num_scenes, num_objects, min_objects, max_objects, num_counterfactuals,
225
+ cf_types, same_cf_type, min_change_score, max_cf_attempts, min_noise_level,
226
+ output_dir, blender_path=None, use_gpu=0, samples=512,
227
+ width=320, height=240, skip_render=False, generate_questions=False):
228
+ if not PIPELINE_AVAILABLE:
229
+ return {
230
+ 'success': False,
231
+ 'error': 'Pipeline functions not available. Please ensure pipeline.py is accessible.'
232
+ }
233
+
234
+ scenes_dir = os.path.join(output_dir, 'scenes')
235
+ images_dir = os.path.join(output_dir, 'images')
236
+ os.makedirs(scenes_dir, exist_ok=True)
237
+ os.makedirs(images_dir, exist_ok=True)
238
+
239
+ script_dir = os.path.dirname(os.path.abspath(__file__))
240
+ cwd = os.getcwd()
241
+ import shutil
242
+ import time
243
+
244
+ temp_output_dir = os.path.join(cwd, 'temp_output')
245
+ if os.path.exists(temp_output_dir):
246
+ for attempt in range(3):
247
+ try:
248
+ shutil.rmtree(temp_output_dir)
249
+ break
250
+ except Exception as e:
251
+ if attempt < 2:
252
+ time.sleep(0.3)
253
+ else:
254
+ print(f"Warning: Could not remove temp_output after 3 attempts: {e}")
255
+
256
+ render_patched_path = os.path.join(cwd, 'render_images_patched.py')
257
+ if os.path.exists(render_patched_path):
258
+ for attempt in range(3):
259
+ try:
260
+ time.sleep(0.2)
261
+ if os.path.exists(render_patched_path):
262
+ os.remove(render_patched_path)
263
+ break
264
+ except Exception as e:
265
+ if attempt < 2:
266
+ time.sleep(0.3)
267
+ else:
268
+ print(f"Warning: Could not remove render_images_patched.py after 3 attempts: {e}")
269
+
270
+ blender_available = False
271
+ if blender_path is None:
272
+ try:
273
+ from pipeline import find_blender
274
+ blender_path = find_blender()
275
+ except:
276
+ blender_path = 'blender'
277
+
278
+ if blender_path and blender_path != 'blender':
279
+ blender_available = os.path.exists(blender_path)
280
+ else:
281
+ try:
282
+ import subprocess
283
+ test_path = blender_path if blender_path and blender_path != 'blender' else 'blender'
284
+ env = os.environ.copy()
285
+ result = subprocess.run([test_path, '--version'], capture_output=True, timeout=5, env=env)
286
+ blender_available = (result.returncode == 0)
287
+ except:
288
+ blender_available = False
289
+
290
+ successful_scenes = 0
291
+ successful_renders = 0
292
+ error_messages = []
293
+
294
+ try:
295
+ for scene_idx in range(num_scenes):
296
+ if num_objects is not None:
297
+ scene_num_objects = num_objects
298
+ else:
299
+ scene_num_objects = random.randint(min_objects, max_objects)
300
+
301
+ base_scene = None
302
+
303
+ if blender_available:
304
+ scene_error = None
305
+ for retry in range(3):
306
+ try:
307
+ import io
308
+ import contextlib
309
+ output_buffer = io.StringIO()
310
+ with contextlib.redirect_stdout(output_buffer), contextlib.redirect_stderr(output_buffer):
311
+ base_scene = generate_base_scene(
312
+ scene_num_objects,
313
+ blender_path,
314
+ scene_idx
315
+ )
316
+ blender_output = output_buffer.getvalue()
317
+ if blender_output and retry == 2:
318
+ st.text(f"Blender output for scene {scene_idx + 1} (last 1000 chars):")
319
+ st.code(blender_output[-1000:] if len(blender_output) > 1000 else blender_output)
320
+
321
+ if base_scene and len(base_scene.get('objects', [])) > 0:
322
+ break
323
+ elif base_scene is None:
324
+ if retry == 2:
325
+ scene_error = f"generate_base_scene returned None - Blender may have failed (check output above)"
326
+ error_messages.append(f"Scene {scene_idx + 1}: {scene_error}")
327
+ elif len(base_scene.get('objects', [])) == 0:
328
+ if retry == 2:
329
+ scene_error = f"Scene has 0 objects - Blender may have hit max_retries (check output above)"
330
+ error_messages.append(f"Scene {scene_idx + 1}: {scene_error}")
331
+ except FileNotFoundError as e:
332
+ scene_error = f"Blender not found: {e}"
333
+ error_messages.append(f"Scene {scene_idx + 1}: {scene_error}")
334
+ blender_available = False
335
+ break
336
+ except Exception as e:
337
+ import traceback
338
+ scene_error = f"Error generating base scene: {str(e)}"
339
+ print(f"Error generating base scene (retry {retry + 1}/3): {e}")
340
+ print(f" Traceback: {traceback.format_exc()}")
341
+ if retry == 2:
342
+ full_error = f"Scene {scene_idx + 1}: {scene_error} (Blender path: {blender_path})"
343
+ error_messages.append(full_error)
344
+ blender_available = False
345
+ continue
346
+ else:
347
+ print(f"Scene {scene_idx + 1} (Blender not available)...")
348
+ base_scene = generate_fallback_scene(scene_num_objects, scene_idx)
349
+
350
+ if not base_scene or len(base_scene.get('objects', [])) == 0:
351
+ error_detail = f"Scene {scene_idx + 1}: Failed to generate"
352
+ if blender_available:
353
+ error_detail += f" (Blender was available at {blender_path} but returned empty scene)"
354
+ else:
355
+ error_detail += " (Blender not available, fallback scene also failed)"
356
+ print(f"Failed to generate scene {scene_idx + 1}")
357
+ print(f" Blender available: {blender_available}")
358
+ print(f" Blender path: {blender_path}")
359
+ print(f" Base scene: {base_scene is not None}")
360
+ if base_scene:
361
+ print(f" Objects in scene: {len(base_scene.get('objects', []))}")
362
+ error_messages.append(error_detail)
363
+ continue
364
+
365
+ successful_scenes += 1
366
+
367
+ counterfactuals = generate_counterfactuals(
368
+ base_scene,
369
+ num_counterfactuals=num_counterfactuals,
370
+ cf_types=cf_types,
371
+ same_cf_type=same_cf_type,
372
+ min_change_score=min_change_score,
373
+ max_cf_attempts=max_cf_attempts,
374
+ min_noise_level='light',
375
+ semantic_only=semantic_only,
376
+ negative_only=negative_only
377
+ )
378
+
379
+ scene_num = scene_idx + 1
380
+ scene_prefix = f"scene_{scene_num:04d}"
381
+
382
+ base_scene['cf_metadata'] = {
383
+ 'variant': 'original',
384
+ 'is_counterfactual': False,
385
+ 'cf_index': None,
386
+ 'cf_category': 'original',
387
+ 'cf_type': None,
388
+ 'cf_description': None,
389
+ 'source_scene': scene_prefix,
390
+ }
391
+ original_scene_path = os.path.join(scenes_dir, f"{scene_prefix}_original.json")
392
+ save_scene(base_scene, original_scene_path)
393
+
394
+ for idx, cf in enumerate(counterfactuals):
395
+ cf_name = f"cf{idx+1}"
396
+ cf_scene = cf['scene']
397
+ cf_scene['cf_metadata'] = {
398
+ 'variant': cf_name,
399
+ 'is_counterfactual': True,
400
+ 'cf_index': idx + 1,
401
+ 'cf_category': cf.get('cf_category', 'unknown'),
402
+ 'cf_type': cf.get('type', None),
403
+ 'cf_description': cf.get('description', None),
404
+ 'change_score': cf.get('change_score', None),
405
+ 'change_attempts': cf.get('change_attempts', None),
406
+ 'source_scene': scene_prefix,
407
+ }
408
+ cf_scene_path = os.path.join(scenes_dir, f"{scene_prefix}_{cf_name}.json")
409
+ save_scene(cf_scene, cf_scene_path)
410
+
411
+ render_success = 0
412
+ total_to_render = len(counterfactuals) + 1
413
+
414
+ if not skip_render:
415
+ if blender_path and blender_available:
416
+ original_image_path = os.path.join(images_dir, f"{scene_prefix}_original.png")
417
+ if render_scene(
418
+ blender_path,
419
+ original_scene_path,
420
+ original_image_path,
421
+ use_gpu=use_gpu,
422
+ samples=samples,
423
+ width=width,
424
+ height=height
425
+ ):
426
+ render_success += 1
427
+
428
+ for idx, cf in enumerate(counterfactuals):
429
+ cf_name = f"cf{idx+1}"
430
+ cf_scene_path = os.path.join(scenes_dir, f"{scene_prefix}_{cf_name}.json")
431
+ cf_image_path = os.path.join(images_dir, f"{scene_prefix}_{cf_name}.png")
432
+
433
+ if render_scene(
434
+ blender_path,
435
+ cf_scene_path,
436
+ cf_image_path,
437
+ use_gpu=use_gpu,
438
+ samples=samples,
439
+ width=width,
440
+ height=height
441
+ ):
442
+ render_success += 1
443
+
444
+ if render_success == total_to_render:
445
+ successful_renders += 1
446
+ else:
447
+ print("Blender not available - skipping image rendering. Scene JSON files will still be generated.")
448
+
449
+ csv_filename = 'image_mapping_with_questions.csv' if generate_questions else 'image_mapping.csv'
450
+ csv_path = os.path.join(output_dir, csv_filename)
451
+
452
+ try:
453
+ if generate_mapping_with_questions is not None:
454
+ generate_mapping_with_questions(
455
+ run_dir=output_dir,
456
+ csv_filename=csv_filename,
457
+ generate_questions=generate_questions,
458
+ with_links=False,
459
+ strict_question_validation=True
460
+ )
461
+ csv_created = os.path.exists(csv_path)
462
+ else:
463
+ csv_created = False
464
+ except Exception:
465
+ import traceback
466
+ traceback.print_exc()
467
+ csv_created = False
468
+
469
+ scene_files = list(Path(scenes_dir).glob("*.json")) if os.path.exists(scenes_dir) else []
470
+ image_files = list(Path(images_dir).glob("*.png")) if os.path.exists(images_dir) else []
471
+
472
+ statistics = {
473
+ 'scenes_generated': successful_scenes,
474
+ 'scenes_rendered': successful_renders,
475
+ 'total_scene_files': len(scene_files),
476
+ 'total_image_files': len(image_files),
477
+ 'num_counterfactuals': num_counterfactuals,
478
+ 'cf_types_used': cf_types if cf_types else 'default',
479
+ 'csv_created': csv_created,
480
+ 'csv_path': csv_path if csv_created else None
481
+ }
482
+
483
+ script_dir = os.path.dirname(os.path.abspath(__file__))
484
+ cwd = os.getcwd()
485
+ import shutil
486
+ import time
487
+
488
+ temp_output_dir = os.path.join(cwd, 'temp_output')
489
+ if os.path.exists(temp_output_dir):
490
+ for attempt in range(3):
491
+ try:
492
+ shutil.rmtree(temp_output_dir)
493
+ break
494
+ except Exception as e:
495
+ if attempt < 2:
496
+ time.sleep(0.3)
497
+ else:
498
+ print(f"Warning: Could not remove temp_output after 3 attempts: {e}")
499
+
500
+ render_patched_path = os.path.join(cwd, 'render_images_patched.py')
501
+ if os.path.exists(render_patched_path):
502
+ for attempt in range(3):
503
+ try:
504
+ time.sleep(0.2)
505
+ if os.path.exists(render_patched_path):
506
+ os.remove(render_patched_path)
507
+ break
508
+ except Exception as e:
509
+ if attempt < 2:
510
+ time.sleep(0.3)
511
+ else:
512
+ print(f"Warning: Could not remove render_images_patched.py after 3 attempts: {e}")
513
+
514
+ if successful_scenes == 0 and error_messages:
515
+ error_summary = "Scenes failed. Common reasons:\n"
516
+ error_summary += "- Blender is not installed or not in PATH\n"
517
+ error_summary += "- Blender executable not found\n"
518
+ error_summary += f"\nFirst error: {error_messages[0] if error_messages else 'Unknown error'}"
519
+
520
+ return {
521
+ 'success': False,
522
+ 'error': error_summary,
523
+ 'num_scenes': successful_scenes,
524
+ 'output_dir': output_dir,
525
+ 'error_messages': error_messages
526
+ }
527
+
528
+ return {
529
+ 'success': True,
530
+ 'num_scenes': successful_scenes,
531
+ 'output_dir': output_dir,
532
+ 'statistics': statistics,
533
+ 'error_messages': error_messages if error_messages else None
534
+ }
535
+
536
+ except Exception as e:
537
+ import traceback
538
+ error_msg = f"Error: {str(e)}\n{traceback.format_exc()}"
539
+ print(error_msg)
540
+ return {
541
+ 'success': False,
542
+ 'error': error_msg,
543
+ 'num_scenes': successful_scenes,
544
+ 'output_dir': output_dir,
545
+ 'error_messages': error_messages if 'error_messages' in locals() else []
546
+ }
547
+
548
+ def main():
549
+ st.markdown('<p class="main-header">Counterfactual Image Generator</p>', unsafe_allow_html=True)
550
+
551
+ if 'output_dir' not in st.session_state:
552
+ st.session_state.output_dir = None
553
+
554
+ if 'generation_complete' not in st.session_state:
555
+ st.session_state.generation_complete = False
556
+
557
+ with st.sidebar:
558
+ st.header("Configuration")
559
+
560
+ st.subheader("Scene Settings")
561
+ num_scenes = st.number_input(
562
+ "Number of Scenes",
563
+ min_value=1,
564
+ max_value=10000,
565
+ value=5,
566
+ help="Number of scene sets to generate"
567
+ )
568
+
569
+ use_fixed_objects = st.checkbox("Use Fixed Number of Objects", value=True)
570
+
571
+ if use_fixed_objects:
572
+ num_objects = st.number_input(
573
+ "Number of Objects per Scene",
574
+ min_value=1,
575
+ max_value=15,
576
+ value=5,
577
+ help="Fixed number of objects per scene"
578
+ )
579
+ min_objects = None
580
+ max_objects = None
581
+ else:
582
+ num_objects = None
583
+ min_objects = st.number_input(
584
+ "Min Objects per Scene",
585
+ min_value=1,
586
+ max_value=15,
587
+ value=3,
588
+ help="Minimum objects per scene"
589
+ )
590
+ max_objects = st.number_input(
591
+ "Max Objects per Scene",
592
+ min_value=1,
593
+ max_value=15,
594
+ value=7,
595
+ help="Maximum objects per scene"
596
+ )
597
+ if min_objects > max_objects:
598
+ st.error("Min objects must be <= Max objects")
599
+ return
600
+
601
+ st.subheader("Counterfactual Settings")
602
+ num_counterfactuals = st.number_input(
603
+ "Number of Counterfactuals",
604
+ min_value=1,
605
+ max_value=10,
606
+ value=2,
607
+ help="Number of counterfactual variants per scene"
608
+ )
609
+
610
+ st.markdown("**Counterfactual Types**")
611
+ st.caption("Leave all unchecked to use default behavior (1 Image CF + 1 Negative CF)")
612
+ semantic_only = st.checkbox(
613
+ "Semantic only",
614
+ value=False,
615
+ help="Generate only Semantic/Image counterfactuals (Change Color, Add Object, etc.); no Negative CFs"
616
+ )
617
+ negative_only = st.checkbox(
618
+ "Negative only",
619
+ value=False,
620
+ help="Generate only Negative counterfactuals (Change Lighting, Add Noise, Occlusion Change, etc.); no Semantic CFs"
621
+ )
622
+ same_cf_type = st.checkbox(
623
+ "Same CF type for all",
624
+ value=False,
625
+ help="Use the same counterfactual type for every variant (first selected type, or one random if none selected)"
626
+ )
627
+ with st.expander("Image CFs (change answers)", expanded=True):
628
+ use_change_color = st.checkbox("Change Color", value=False)
629
+ use_change_shape = st.checkbox("Change Shape", value=False)
630
+ use_change_size = st.checkbox("Change Size", value=False)
631
+ use_change_material = st.checkbox("Change Material", value=False)
632
+ use_change_position = st.checkbox("Change Position", value=False)
633
+ use_add_object = st.checkbox("Add Object", value=False)
634
+ use_remove_object = st.checkbox("Remove Object", value=False)
635
+ use_replace_object = st.checkbox("Replace Object", value=False)
636
+ use_swap_attribute = st.checkbox("Swap Attribute", value=False)
637
+ use_relational_flip = st.checkbox("Relational Flip", value=False)
638
+
639
+ with st.expander("Negative CFs (don't change answers)", expanded=False):
640
+ use_change_background = st.checkbox("Change Background", value=False)
641
+ use_change_lighting = st.checkbox("Change Lighting", value=False)
642
+ use_add_noise = st.checkbox("Add Noise", value=False)
643
+ use_occlusion_change = st.checkbox("Occlusion Change", value=False)
644
+ use_apply_fisheye = st.checkbox("Apply Fisheye", value=False)
645
+ use_apply_blur = st.checkbox("Apply Blur", value=False)
646
+ use_apply_vignette = st.checkbox("Apply Vignette", value=False)
647
+ use_apply_chromatic_aberration = st.checkbox("Apply Chromatic Aberration", value=False)
648
+
649
+ with st.expander("Advanced Settings", expanded=False):
650
+ min_change_score = st.slider(
651
+ "Minimum Change Score",
652
+ min_value=0.5,
653
+ max_value=5.0,
654
+ value=1.0,
655
+ step=0.1,
656
+ help="Minimum heuristic change score for counterfactuals"
657
+ )
658
+
659
+ max_cf_attempts = st.number_input(
660
+ "Max CF Attempts",
661
+ min_value=1,
662
+ max_value=50,
663
+ value=10,
664
+ help="Maximum retries per counterfactual"
665
+ )
666
+
667
+ min_noise_level = st.selectbox(
668
+ "Min Noise Level (for add_noise CF)",
669
+ options=['light', 'medium', 'heavy'],
670
+ index=0,
671
+ help="Minimum noise level when using add_noise counterfactual"
672
+ )
673
+
674
+ st.markdown("---")
675
+ st.markdown("**Rendering Settings**")
676
+
677
+ use_gpu = st.checkbox("Use GPU Rendering", value=False)
678
+ use_gpu_int = 1 if use_gpu else 0
679
+
680
+ samples = st.number_input(
681
+ "Render Samples",
682
+ min_value=64,
683
+ max_value=2048,
684
+ value=512,
685
+ step=64,
686
+ help="Cycles sampling rate (higher = better quality, slower)"
687
+ )
688
+
689
+ image_width = st.number_input(
690
+ "Image Width",
691
+ min_value=160,
692
+ max_value=1920,
693
+ value=320,
694
+ step=80
695
+ )
696
+
697
+ image_height = st.number_input(
698
+ "Image Height",
699
+ min_value=120,
700
+ max_value=1080,
701
+ value=240,
702
+ step=60
703
+ )
704
+
705
+ st.markdown("**CSV Options**")
706
+ generate_questions = st.checkbox(
707
+ "Generate Questions in CSV",
708
+ value=False,
709
+ help="Include question and answer columns in the CSV file"
710
+ )
711
+
712
+ cf_types = []
713
+ if use_change_color:
714
+ cf_types.append('change_color')
715
+ if use_change_shape:
716
+ cf_types.append('change_shape')
717
+ if use_change_size:
718
+ cf_types.append('change_size')
719
+ if use_change_material:
720
+ cf_types.append('change_material')
721
+ if use_change_position:
722
+ cf_types.append('change_position')
723
+ if use_add_object:
724
+ cf_types.append('add_object')
725
+ if use_remove_object:
726
+ cf_types.append('remove_object')
727
+ if use_replace_object:
728
+ cf_types.append('replace_object')
729
+ if use_swap_attribute:
730
+ cf_types.append('swap_attribute')
731
+ if use_relational_flip:
732
+ cf_types.append('relational_flip')
733
+ if use_change_background:
734
+ cf_types.append('change_background')
735
+ if use_change_lighting:
736
+ cf_types.append('change_lighting')
737
+ if use_add_noise:
738
+ cf_types.append('add_noise')
739
+ if use_occlusion_change:
740
+ cf_types.append('occlusion_change')
741
+ if use_apply_fisheye:
742
+ cf_types.append('apply_fisheye')
743
+ if use_apply_blur:
744
+ cf_types.append('apply_blur')
745
+ if use_apply_vignette:
746
+ cf_types.append('apply_vignette')
747
+ if use_apply_chromatic_aberration:
748
+ cf_types.append('apply_chromatic_aberration')
749
+
750
+ if not cf_types:
751
+ cf_types = None
752
+
753
+ col1, col2 = st.columns([2, 1])
754
+
755
+ with col1:
756
+ st.header("Generate Counterfactual Images")
757
+
758
+ if st.button("Generate Counterfactual", use_container_width=True, key="generate_button"):
759
+ st.session_state.generation_complete = False
760
+ st.session_state.generating = True
761
+
762
+ if num_scenes < 1:
763
+ st.error("Please specify at least 1 scene to generate.")
764
+ return
765
+
766
+ if use_fixed_objects and num_objects < 1:
767
+ st.error("Please specify at least 1 object per scene.")
768
+ return
769
+ if not use_fixed_objects and (min_objects < 1 or max_objects < 1 or min_objects > max_objects):
770
+ st.error("Invalid min/max objects configuration.")
771
+ return
772
+
773
+ if os.path.exists('/tmp'):
774
+ base_dir = '/tmp'
775
+ else:
776
+ base_dir = tempfile.gettempdir()
777
+
778
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
779
+ output_dir = os.path.join(base_dir, f"counterfactual_output_{timestamp}")
780
+ os.makedirs(output_dir, exist_ok=True)
781
+ st.session_state.output_dir = output_dir
782
+
783
+ import shutil
784
+ import time
785
+ script_dir = os.path.dirname(os.path.abspath(__file__))
786
+ cwd = os.getcwd()
787
+
788
+ temp_output_dir = os.path.join(cwd, 'temp_output')
789
+ if os.path.exists(temp_output_dir):
790
+ for attempt in range(3):
791
+ try:
792
+ shutil.rmtree(temp_output_dir)
793
+ break
794
+ except Exception as e:
795
+ if attempt < 2:
796
+ time.sleep(0.3)
797
+ else:
798
+ print(f"Warning: Could not remove temp_output after 3 attempts: {e}")
799
+
800
+ render_patched_path = os.path.join(cwd, 'render_images_patched.py')
801
+ if os.path.exists(render_patched_path):
802
+ for attempt in range(3):
803
+ try:
804
+ time.sleep(0.2)
805
+ if os.path.exists(render_patched_path):
806
+ os.remove(render_patched_path)
807
+ break
808
+ except Exception as e:
809
+ if attempt < 2:
810
+ time.sleep(0.3)
811
+ else:
812
+ print(f"Warning: Could not remove render_images_patched.py after 3 attempts: {e}")
813
+
814
+ try:
815
+ from pipeline import create_patched_render_script
816
+ create_patched_render_script()
817
+ except Exception as e:
818
+ st.warning(f"Could not create patched render script: {e}")
819
+
820
+ params = {
821
+ 'num_scenes': num_scenes,
822
+ 'num_objects': num_objects,
823
+ 'num_counterfactuals': num_counterfactuals,
824
+ 'cf_types': cf_types if cf_types else None,
825
+ 'same_cf_type': same_cf_type,
826
+ 'min_change_score': min_change_score,
827
+ 'max_cf_attempts': max_cf_attempts,
828
+ 'width': image_width,
829
+ 'height': image_height,
830
+ 'output_dir': output_dir
831
+ }
832
+
833
+ progress_bar = st.progress(0)
834
+ status_text = st.empty()
835
+
836
+ try:
837
+ if not PIPELINE_AVAILABLE:
838
+ st.error("Pipeline functions are not available. Please check your installation.")
839
+ return
840
+
841
+ status_text.text("Initializing generator...")
842
+ progress_bar.progress(10)
843
+
844
+ if use_fixed_objects:
845
+ status_text.text(f"Generating {num_scenes} scenes with {num_objects} objects each...")
846
+ else:
847
+ status_text.text(f"Generating {num_scenes} scenes with {min_objects}-{max_objects} objects each...")
848
+ progress_bar.progress(30)
849
+
850
+ result = generate_counterfactual_scenes(
851
+ num_scenes=num_scenes,
852
+ num_objects=num_objects,
853
+ min_objects=min_objects,
854
+ max_objects=max_objects,
855
+ num_counterfactuals=num_counterfactuals,
856
+ cf_types=cf_types,
857
+ same_cf_type=same_cf_type,
858
+ min_change_score=min_change_score,
859
+ max_cf_attempts=max_cf_attempts,
860
+ min_noise_level=min_noise_level,
861
+ output_dir=output_dir,
862
+ use_gpu=use_gpu_int,
863
+ samples=samples,
864
+ width=image_width,
865
+ height=image_height,
866
+ skip_render=False,
867
+ generate_questions=generate_questions
868
+ )
869
+
870
+ progress_bar.progress(80)
871
+ status_text.text("Preparing output...")
872
+
873
+ if result and result.get('success', False):
874
+ num_scenes_generated = result.get('num_scenes', 0)
875
+
876
+ if num_scenes_generated == 0:
877
+ st.warning("No scenes were created. Blender is required and is not available in this environment.")
878
+ st.info("**To use this application:**\n"
879
+ "1. Run it locally with Blender installed\n"
880
+ "2. Use the command-line `pipeline.py` script\n"
881
+ "3. Install Blender and ensure it's in your system PATH")
882
+ st.session_state.generation_complete = False
883
+ else:
884
+ st.session_state.generation_complete = True
885
+ progress_bar.progress(100)
886
+ status_text.text("Done.")
887
+
888
+ st.success(f"Successfully generated {num_scenes_generated} scene sets!")
889
+ st.info(f"Output directory: {output_dir}")
890
+
891
+ if 'statistics' in result and result['statistics'].get('csv_created'):
892
+ csv_path = result['statistics'].get('csv_path')
893
+ if csv_path:
894
+ st.success(f"CSV file created: `{os.path.basename(csv_path)}`")
895
+
896
+ if 'statistics' in result:
897
+ stats = result['statistics']
898
+ st.json(stats)
899
+ else:
900
+ error_msg = result.get('error', 'Unknown error occurred') if result else 'Failed'
901
+ st.error(f"Generation failed: {error_msg}")
902
+
903
+ if 'blender' in error_msg.lower() or 'Blender' in error_msg or result.get('num_scenes', 0) == 0:
904
+ st.warning("**Important:** This application requires Blender to generate scenes. Blender is not available on Hugging Face Spaces.")
905
+ st.info("**To use this application:**\n"
906
+ "1. Run it locally with Blender installed\n"
907
+ "2. Use the command-line `pipeline.py` script\n"
908
+ "3. Install Blender and ensure it's in your system PATH")
909
+
910
+ st.session_state.generation_complete = False
911
+ st.session_state.generating = False
912
+
913
+ except Exception as e:
914
+ st.error(f"Error during generation: {str(e)}")
915
+ st.exception(e)
916
+ st.session_state.generation_complete = False
917
+ st.session_state.generating = False
918
+ progress_bar.progress(0)
919
+ status_text.text("Failed")
920
+
921
+ with col2:
922
+ st.header("Output")
923
+
924
+ if st.session_state.generation_complete and st.session_state.output_dir:
925
+ output_dir = st.session_state.output_dir
926
+
927
+ if os.path.exists(output_dir):
928
+ images_dir = os.path.join(output_dir, 'images')
929
+ scenes_dir = os.path.join(output_dir, 'scenes')
930
+
931
+ scene_files = list(Path(scenes_dir).glob("*.json")) if os.path.exists(scenes_dir) else []
932
+ image_files = list(Path(images_dir).glob("*.png")) if os.path.exists(images_dir) else []
933
+ csv_files = list(Path(output_dir).rglob("*.csv"))
934
+
935
+ st.success("Complete!")
936
+ st.metric("Scene Files", len(scene_files))
937
+ st.metric("CSV Files", len(csv_files))
938
+ st.metric("Image Files", len(image_files))
939
+
940
+ if image_files:
941
+ st.markdown("---")
942
+ st.subheader("Generated Images")
943
+
944
+ def get_counterfactual_type_from_scene(scene_file):
945
+ try:
946
+ with open(scene_file, 'r') as f:
947
+ scene_data = json.load(f)
948
+ cf_metadata = scene_data.get('cf_metadata', {})
949
+ cf_type = cf_metadata.get('cf_type', '')
950
+ if cf_type:
951
+ return cf_type.replace('_', ' ').title()
952
+ except Exception as e:
953
+ pass
954
+ return "Counterfactual"
955
+
956
+ scene_sets = {}
957
+ for img_file in image_files:
958
+ filename = img_file.name
959
+ if filename.startswith('scene_'):
960
+ parts = filename.replace('.png', '').split('_')
961
+ if len(parts) >= 3:
962
+ scene_num = parts[1]
963
+ scene_type = parts[2]
964
+
965
+ if scene_num not in scene_sets:
966
+ scene_sets[scene_num] = {}
967
+
968
+ scene_sets[scene_num][scene_type] = {
969
+ 'image_path': str(img_file),
970
+ 'filename': filename
971
+ }
972
+
973
+ sorted_scenes = sorted(scene_sets.keys())[:3]
974
+
975
+ for scene_idx, scene_num in enumerate(sorted_scenes):
976
+ scene_data = scene_sets[scene_num]
977
+
978
+ if 'original' not in scene_data:
979
+ continue
980
+
981
+ st.markdown(f"### Scene {scene_num}")
982
+
983
+ cols = st.columns(3)
984
+
985
+ with cols[0]:
986
+ original = scene_data['original']
987
+ st.image(original['image_path'], use_container_width=True, caption="Original")
988
+
989
+ cf_count = 0
990
+ for cf_key in ['cf1', 'cf2']:
991
+ if cf_key in scene_data and cf_count < 2:
992
+ cf_data = scene_data[cf_key]
993
+ cf_scene_file = os.path.join(scenes_dir, cf_data['filename'].replace('.png', '.json'))
994
+ cf_type = get_counterfactual_type_from_scene(cf_scene_file) if os.path.exists(cf_scene_file) else f"Counterfactual {cf_count + 1}"
995
+
996
+ with cols[cf_count + 1]:
997
+ st.image(cf_data['image_path'], use_container_width=True, caption=cf_type)
998
+
999
+ cf_count += 1
1000
+
1001
+ if scene_idx < len(sorted_scenes) - 1:
1002
+ st.markdown("---")
1003
+
1004
+ st.markdown("---")
1005
+ st.subheader("Download Output")
1006
+
1007
+ zip_filename = f"counterfactual_output_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
1008
+ zip_path = os.path.join(tempfile.gettempdir(), zip_filename)
1009
+
1010
+ try:
1011
+ create_zip_file(output_dir, zip_path)
1012
+
1013
+ file_size = os.path.getsize(zip_path) / (1024 * 1024)
1014
+
1015
+ with open(zip_path, 'rb') as f:
1016
+ st.download_button(
1017
+ label=f"Download as ZIP ({file_size:.2f} MB)",
1018
+ data=f.read(),
1019
+ file_name=zip_filename,
1020
+ mime="application/zip",
1021
+ use_container_width=True
1022
+ )
1023
+
1024
+ with st.expander("Output Structure"):
1025
+ st.text(f"Output directory: {output_dir}")
1026
+ if scene_files:
1027
+ st.text(f"\nScene files: {len(scene_files)}")
1028
+ st.text("Sample files:")
1029
+ for f in scene_files[:5]:
1030
+ st.text(f" - {f.name}")
1031
+ if csv_files:
1032
+ st.text(f"\nCSV files: {len(csv_files)}")
1033
+ for f in csv_files:
1034
+ st.text(f" - {f.name}")
1035
+ if image_files:
1036
+ st.text(f"\nImage files: {len(image_files)}")
1037
+ st.text("Sample files:")
1038
+ for f in image_files[:5]:
1039
+ st.text(f" - {f.name}")
1040
+
1041
+ except Exception as e:
1042
+ st.error(f"Error creating zip file: {str(e)}")
1043
+ else:
1044
+ st.warning("Output directory not found.")
1045
+ else:
1046
+ st.info("Configure parameters and click 'Generate Counterfactual' to start.")
1047
+
1048
+ st.markdown("---")
1049
+ st.markdown(
1050
+ "<div style='text-align: center; color: #666; padding: 1rem;'>"
1051
+ "Counterfactual Image Tool | Built with Streamlit"
1052
+ "</div>",
1053
+ unsafe_allow_html=True
1054
+ )
1055
+
1056
+ if __name__ == "__main__":
1057
+ main()
1058
+
data/CoGenT_A.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cube": [
3
+ "gray", "blue", "brown", "yellow"
4
+ ],
5
+ "cylinder": [
6
+ "red", "green", "purple", "cyan"
7
+ ],
8
+ "sphere": [
9
+ "gray", "red", "blue", "green", "brown", "purple", "cyan", "yellow"
10
+ ]
11
+ }
data/CoGenT_B.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cube": [
3
+ "red", "green", "purple", "cyan"
4
+ ],
5
+ "cylinder": [
6
+ "gray", "blue", "brown", "yellow"
7
+ ],
8
+ "sphere": [
9
+ "gray", "red", "blue", "green", "brown", "purple", "cyan", "yellow"
10
+ ]
11
+ }
data/base_scene.blend ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e9a6a0a51f377064ae78e6568fdb1c5a6c97db4e674b29682e345d677607eb82
3
+ size 512864
data/materials/MyMetal.blend ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e47b6f920aaa4f5db0306eed01c309e0fcd933fdf2a8b6032a5fe5012268eca6
3
+ size 1822252
data/materials/Rubber.blend ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:05aa7d57452a4f083febe9ba34483c3c66201d2836b7281794f7ddbd9f9e63ff
3
+ size 1847100
data/properties.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "shapes": {
3
+ "cube": "SmoothCube_v2",
4
+ "sphere": "Sphere",
5
+ "cylinder": "SmoothCylinder"
6
+ },
7
+ "colors": {
8
+ "gray": [87, 87, 87],
9
+ "red": [173, 35, 35],
10
+ "blue": [42, 75, 215],
11
+ "green": [29, 105, 20],
12
+ "brown": [129, 74, 25],
13
+ "purple": [129, 38, 192],
14
+ "cyan": [41, 208, 208],
15
+ "yellow": [255, 238, 51]
16
+ },
17
+ "materials": {
18
+ "rubber": "Rubber",
19
+ "metal": "MyMetal"
20
+ },
21
+ "sizes": {
22
+ "large": 0.7,
23
+ "small": 0.35
24
+ }
25
+ }
data/shapes/SmoothCube_v2.blend ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a901f32efbc2afe33de6874163002439ce943697d6f4b43d0ad494eac72035ce
3
+ size 750148
data/shapes/SmoothCylinder.blend ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f5c4bf6b1f5e1469eb868b4e6be092ae2656b7b41d3a07e23d8487e616289bfa
3
+ size 3434292
data/shapes/Sphere.blend ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f35d20b842193ac0f3e49d4642c1ebbc999142ead5ecc298ed024118a5e5df98
3
+ size 1753684
docker-compose.yml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ streamlit-app:
5
+ build:
6
+ context: .
7
+ dockerfile: Dockerfile
8
+ container_name: counterfactual-generator
9
+ ports:
10
+ - "8501:8501"
11
+ volumes:
12
+ # Mount data directory if needed
13
+ - ./data:/app/data:ro
14
+ # Mount output directory for persistence
15
+ - ./output:/app/output
16
+ # Mount temp output directory
17
+ - ./temp_output:/app/temp_output
18
+ environment:
19
+ - PYTHONUNBUFFERED=1
20
+ restart: unless-stopped
21
+ healthcheck:
22
+ test: ["CMD", "python", "-c", "import streamlit; print('OK')"]
23
+ interval: 30s
24
+ timeout: 10s
25
+ retries: 3
26
+ start_period: 40s
generate_semantic_semibalanced_200.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import os
3
+ from pathlib import Path
4
+
5
+
6
+ def main() -> None:
7
+ base_dir = Path(__file__).resolve().parent
8
+
9
+ # Input mapping file (question mapping)
10
+ mapping_path = base_dir / "output" / "200samples_mmib_rel" / "image_mapping_with_questions_200_rel.csv"
11
+
12
+ # Output directory and file for the semantic semibalanced 200 subset
13
+ out_dir = base_dir / "output" / "semantic_semibalanced_200"
14
+ out_dir.mkdir(parents=True, exist_ok=True)
15
+ out_csv = out_dir / "balanced_benchmark_200_semantic.csv"
16
+
17
+ if not mapping_path.is_file():
18
+ raise FileNotFoundError(f"Mapping CSV not found at {mapping_path}")
19
+
20
+ with mapping_path.open(newline="", encoding="utf-8") as f_in:
21
+ reader = csv.DictReader(f_in)
22
+ rows = list(reader)
23
+ fieldnames = reader.fieldnames
24
+
25
+ if fieldnames is None:
26
+ raise ValueError("No header found in mapping CSV.")
27
+
28
+ # We require that the original question's answer on the base image
29
+ # differs from the answer to the same (original) question on the cf1 image.
30
+ # Also drop rows where either of these answers is unknown.
31
+ required_cols = [
32
+ "original_image_answer_to_original_question",
33
+ "cf_image_answer_to_original_question",
34
+ ]
35
+ for col in required_cols:
36
+ if col not in fieldnames:
37
+ raise KeyError(f"Expected column '{col}' not found in mapping CSV.")
38
+
39
+ filtered_rows = []
40
+ for row in rows:
41
+ base_ans = row["original_image_answer_to_original_question"]
42
+ cf1_ans = row["cf_image_answer_to_original_question"]
43
+
44
+ if base_ans.lower() == "unknown" or cf1_ans.lower() == "unknown":
45
+ continue
46
+
47
+ if base_ans != cf1_ans:
48
+ filtered_rows.append(row)
49
+
50
+ with out_csv.open("w", newline="", encoding="utf-8") as f_out:
51
+ writer = csv.DictWriter(f_out, fieldnames=fieldnames)
52
+ writer.writeheader()
53
+ writer.writerows(filtered_rows)
54
+
55
+ print(
56
+ f"Wrote {len(filtered_rows)} rows (from {len(rows)}) "
57
+ f"to {out_csv.relative_to(base_dir)}"
58
+ )
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
63
+
pipeline.py ADDED
@@ -0,0 +1,2200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+
3
+ import json
4
+ import argparse
5
+ import os
6
+ import subprocess
7
+ import csv
8
+ import random
9
+ import copy
10
+ import sys
11
+ import shutil
12
+ import time
13
+ import math
14
+ import glob
15
+ import zipfile
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+
19
+ script_dir = os.path.dirname(os.path.abspath(__file__))
20
+ sys.path.insert(0, os.path.join(script_dir, 'scripts'))
21
+ try:
22
+ from generate_questions_mapping import generate_mapping_with_questions
23
+ except ImportError:
24
+ generate_mapping_with_questions = None
25
+
26
+ def find_blender():
27
+ hf_paths = [
28
+ '/opt/blender/blender',
29
+ '/usr/local/bin/blender',
30
+ ]
31
+
32
+ home = os.environ.get('HOME', os.path.expanduser('~'))
33
+ user_blender_paths = [
34
+ os.path.join(home, 'blender', 'blender'),
35
+ os.path.join(home, '.local', 'bin', 'blender'),
36
+ ]
37
+ hf_paths.extend(user_blender_paths)
38
+
39
+ for path in hf_paths:
40
+ if os.path.exists(path):
41
+ return path
42
+
43
+ if sys.platform == 'win32':
44
+ common_paths = [
45
+ r"C:\Program Files\Blender Foundation\Blender 4.5\blender.exe",
46
+ r"C:\Program Files\Blender Foundation\Blender 4.3\blender.exe",
47
+ r"C:\Program Files\Blender Foundation\Blender 4.2\blender.exe",
48
+ ]
49
+ for path in common_paths:
50
+ if os.path.exists(path):
51
+ return path
52
+
53
+ try:
54
+ env = os.environ.copy()
55
+ result = subprocess.run(['which', 'blender'], capture_output=True, timeout=5, env=env)
56
+ if result.returncode == 0:
57
+ blender_path = result.stdout.decode().strip()
58
+ if blender_path and os.path.exists(blender_path):
59
+ return blender_path
60
+ except:
61
+ pass
62
+
63
+ home = os.environ.get('HOME', os.path.expanduser('~'))
64
+ blender_default = os.path.join(home, 'blender', 'blender')
65
+ if os.path.exists(blender_default):
66
+ return blender_default
67
+
68
+ return 'blender'
69
+
70
+
71
+ def create_run_directory(base_output_dir, run_name=None):
72
+ if run_name:
73
+ run_name = "".join(c for c in run_name if c.isalnum() or c in ('_', '-'))
74
+ run_dir = os.path.join(base_output_dir, run_name)
75
+ else:
76
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
77
+ run_dir = os.path.join(base_output_dir, timestamp)
78
+
79
+ os.makedirs(run_dir, exist_ok=True)
80
+ return run_dir
81
+
82
+ def save_checkpoint(checkpoint_file, completed_scenes):
83
+ with open(checkpoint_file, 'w') as f:
84
+ json.dump({'completed_scenes': completed_scenes}, f)
85
+ print(f" [CHECKPOINT] Saved: {len(completed_scenes)} scenes completed")
86
+
87
+ def load_checkpoint(checkpoint_file):
88
+ if os.path.exists(checkpoint_file):
89
+ with open(checkpoint_file, 'r') as f:
90
+ data = json.load(f)
91
+ return set(data.get('completed_scenes', []))
92
+ return set()
93
+
94
+ def get_completed_scenes_from_folder(images_dir):
95
+ if not os.path.exists(images_dir):
96
+ return set()
97
+
98
+ completed = set()
99
+ files = os.listdir(images_dir)
100
+
101
+ for f in files:
102
+ if f.startswith('scene_') and '_original.png' in f:
103
+ try:
104
+ scene_num = int(f.split('_')[1])
105
+ completed.add(scene_num)
106
+ except (IndexError, ValueError):
107
+ continue
108
+
109
+ return completed
110
+
111
+ def create_patched_utils():
112
+ return None
113
+
114
+ def create_patched_render_script():
115
+ current_dir = os.path.dirname(os.path.abspath(__file__))
116
+ render_path = os.path.join(current_dir, 'scripts', 'render.py')
117
+
118
+ with open(render_path, 'r', encoding='utf-8') as f:
119
+ original_content = f.read()
120
+
121
+ patched_content = original_content
122
+
123
+ import_patterns = [
124
+ 'if INSIDE_BLENDER:\n import sys\n import os\n current_dir = os.path.dirname(os.path.abspath(__file__))\n if current_dir not in sys.path:\n sys.path.insert(0, current_dir)\n \n try:\n import utils',
125
+ 'if INSIDE_BLENDER:\n try:\n import utils',
126
+ ]
127
+
128
+ for pattern in import_patterns:
129
+ if pattern in patched_content:
130
+ import_start = patched_content.find(pattern)
131
+ if import_start != -1:
132
+ import_end = patched_content.find(' sys.exit(1)', import_start)
133
+ if import_end == -1:
134
+ import_end = patched_content.find('\nparser =', import_start)
135
+ if import_end != -1:
136
+ import_end = patched_content.find('\n', import_end)
137
+ patched_content = patched_content[:import_start] + patched_content[import_end:]
138
+ break
139
+
140
+ patched_content = patched_content.replace(
141
+ ' render_args.tile_x = args.render_tile_size\n render_args.tile_y = args.render_tile_size',
142
+ ' pass'
143
+ )
144
+
145
+ patched_content = patched_content.replace("render_args.engine = 'BLENDER_EEVEE'", "render_args.engine = 'CYCLES'")
146
+
147
+ patched_content = patched_content.replace(
148
+ "bpy.data.worlds['World'].cycles.sample_as_light = True",
149
+ "pass"
150
+ )
151
+ patched_content = patched_content.replace(
152
+ "bpy.context.scene.cycles.transparent_min_bounces = args.render_min_bounces",
153
+ "pass"
154
+ )
155
+ patched_content = patched_content.replace(
156
+ "bpy.context.scene.cycles.transparent_max_bounces = args.render_max_bounces",
157
+ "pass"
158
+ )
159
+
160
+ if "margins_good = True" in patched_content and "BROKEN MARGIN!" in patched_content:
161
+ old_margin_check = """ for direction_name in ['left', 'right', 'front', 'behind']:
162
+ direction_vec = scene_struct['directions'][direction_name]
163
+ assert direction_vec[2] == 0
164
+ margin = dx * direction_vec[0] + dy * direction_vec[1]
165
+ if 0 < margin < args.margin:
166
+ print(margin, args.margin, direction_name)
167
+ print('BROKEN MARGIN!')
168
+ margins_good = False
169
+ break"""
170
+
171
+ new_margin_check = """ pass"""
172
+
173
+ if old_margin_check in patched_content:
174
+ patched_content = patched_content.replace(old_margin_check, new_margin_check)
175
+
176
+ patched_content = patched_content.replace(
177
+ "parser.add_argument('--min_pixels_per_object', default=200, type=int,",
178
+ "parser.add_argument('--min_pixels_per_object', default=50, type=int,"
179
+ )
180
+ patched_content = patched_content.replace(
181
+ "parser.add_argument('--max_retries', default=50, type=int,",
182
+ "parser.add_argument('--max_retries', default=200, type=int,"
183
+ )
184
+ patched_content = patched_content.replace(
185
+ "parser.add_argument('--max_retries', default=100, type=int,",
186
+ "parser.add_argument('--max_retries', default=200, type=int,"
187
+ )
188
+ patched_content = patched_content.replace(
189
+ "parser.add_argument('--margin', default=0.4, type=float,",
190
+ "parser.add_argument('--margin', default=0.05, type=float,"
191
+ )
192
+ patched_content = patched_content.replace(
193
+ "parser.add_argument('--margin', default=0.2, type=float,",
194
+ "parser.add_argument('--margin', default=0.05, type=float,"
195
+ )
196
+ patched_content = patched_content.replace(
197
+ "parser.add_argument('--min_dist', default=0.25, type=float,",
198
+ "parser.add_argument('--min_dist', default=0.15, type=float,"
199
+ )
200
+
201
+ script_path = os.path.abspath('render_images_patched.py')
202
+ with open(script_path, 'w', encoding='utf-8') as f:
203
+ f.write(patched_content)
204
+
205
+ return script_path
206
+
207
+ def create_render_from_json_script():
208
+ current_dir = os.path.dirname(os.path.abspath(__file__))
209
+ render_path = os.path.join(current_dir, 'scripts', 'render.py')
210
+ return render_path
211
+
212
+ def generate_base_scene(num_objects, blender_path, scene_idx, temp_run_dir=None):
213
+ cwd = os.getcwd()
214
+ script_file = os.path.abspath(__file__)
215
+ current_dir = os.path.dirname(script_file)
216
+ temp_dir_to_clean = None
217
+
218
+ possible_dirs = [
219
+ current_dir,
220
+ cwd,
221
+ os.path.join(os.environ.get('HOME', ''), 'app'),
222
+ '/home/user/app',
223
+ os.path.dirname(cwd),
224
+ '/app',
225
+ ]
226
+
227
+ data_dir = None
228
+ for possible_dir in possible_dirs:
229
+ if not possible_dir:
230
+ continue
231
+ test_data_dir = os.path.join(possible_dir, 'data')
232
+ if os.path.exists(test_data_dir) and os.path.exists(os.path.join(test_data_dir, 'base_scene.blend')):
233
+ data_dir = test_data_dir
234
+ break
235
+
236
+ if data_dir is None:
237
+ print(f" ERROR: Could not find data directory")
238
+ print(f" Searched in:")
239
+ for pd in possible_dirs:
240
+ if pd:
241
+ test_path = os.path.join(pd, 'data')
242
+ exists = os.path.exists(test_path)
243
+ print(f" - {test_path}: {'EXISTS' if exists else 'NOT FOUND'}")
244
+ if exists:
245
+ try:
246
+ contents = os.listdir(test_path)
247
+ print(f" Contents: {contents[:5]}")
248
+ except:
249
+ pass
250
+ print(f" Current working directory: {cwd}")
251
+ print(f" Script directory: {current_dir}")
252
+ print(f" Script file: {script_file}")
253
+ print(f" HOME: {os.environ.get('HOME', 'NOT SET')}")
254
+ return None
255
+
256
+ if temp_run_dir is None:
257
+ temp_run_dir = os.path.join(cwd, 'temp_output', f"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{scene_idx}")
258
+ temp_dir_to_clean = temp_run_dir
259
+ else:
260
+ temp_run_dir = os.path.join(cwd, 'temp_output', temp_run_dir) if not os.path.isabs(temp_run_dir) else temp_run_dir
261
+
262
+ temp_images_dir = os.path.join(temp_run_dir, 'images')
263
+ temp_scenes_dir = os.path.join(temp_run_dir, 'scenes')
264
+
265
+ os.makedirs(temp_images_dir, exist_ok=True)
266
+ os.makedirs(temp_scenes_dir, exist_ok=True)
267
+
268
+ try:
269
+ patched_script = create_patched_render_script()
270
+
271
+ if blender_path == 'blender':
272
+ blender_path = find_blender()
273
+
274
+ if not os.path.exists(blender_path) and blender_path != 'blender':
275
+ print(f" ERROR: Blender path does not exist: {blender_path}")
276
+ return None
277
+
278
+ base_scene = os.path.join(data_dir, 'base_scene.blend')
279
+ properties_json = os.path.join(data_dir, 'properties.json')
280
+ shape_dir = os.path.join(data_dir, 'shapes')
281
+ material_dir = os.path.join(data_dir, 'materials')
282
+
283
+ base_scene = os.path.abspath(base_scene)
284
+ properties_json = os.path.abspath(properties_json)
285
+ shape_dir = os.path.abspath(shape_dir)
286
+ material_dir = os.path.abspath(material_dir)
287
+ temp_images_dir = os.path.abspath(temp_images_dir)
288
+ temp_scenes_dir = os.path.abspath(temp_scenes_dir)
289
+
290
+ if not os.path.exists(base_scene):
291
+ print(f" ERROR: Base scene file not found: {base_scene}")
292
+ print(f" Data directory contents: {os.listdir(data_dir) if os.path.exists(data_dir) else 'N/A'}")
293
+ return None
294
+ if not os.path.exists(properties_json):
295
+ print(f" ERROR: Properties JSON not found: {properties_json}")
296
+ return None
297
+ if not os.path.exists(shape_dir):
298
+ print(f" ERROR: Shape directory not found: {shape_dir}")
299
+ if os.path.exists(data_dir):
300
+ print(f" Data directory contents: {os.listdir(data_dir)}")
301
+ return None
302
+ if not os.path.exists(material_dir):
303
+ print(f" ERROR: Material directory not found: {material_dir}")
304
+ return None
305
+
306
+ env = os.environ.copy()
307
+
308
+ cmd = [
309
+ blender_path, '--background', '-noaudio', '--python', patched_script, '--',
310
+ '--num_images', '1',
311
+ '--start_idx', str(scene_idx),
312
+ '--min_objects', str(num_objects),
313
+ '--max_objects', str(num_objects),
314
+ '--min_pixels_per_object', '50',
315
+ '--min_dist', '0.20',
316
+ '--margin', '0.2',
317
+ '--max_retries', '100',
318
+ '--output_image_dir', temp_images_dir,
319
+ '--output_scene_dir', temp_scenes_dir,
320
+ '--filename_prefix', f'SCENE_{scene_idx:04d}',
321
+ '--base_scene_blendfile', base_scene,
322
+ '--properties_json', properties_json,
323
+ '--shape_dir', shape_dir,
324
+ '--material_dir', material_dir,
325
+ ]
326
+
327
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env, cwd=cwd)
328
+
329
+ if result.returncode != 0:
330
+ print(f"ERROR: Blender returned code {result.returncode}")
331
+ print(f" Blender path used: {blender_path}")
332
+ print(f" Command: {' '.join(cmd[:3])}...")
333
+ if result.stderr:
334
+ print(f" Error details (last 1000 chars): {result.stderr[-1000:]}")
335
+ if result.stdout:
336
+ error_lines = [line for line in result.stdout.split('\n') if 'error' in line.lower() or 'Error' in line or 'ERROR' in line or 'Traceback' in line]
337
+ if error_lines:
338
+ print(f" Error lines from stdout: {error_lines[-10:]}")
339
+ print(f" Full stdout (last 500 chars): {result.stdout[-500:]}")
340
+ return None
341
+
342
+ if not os.path.exists(temp_scenes_dir):
343
+ print(f"ERROR: temp run scenes directory does not exist")
344
+ print(f" Expected: {temp_scenes_dir}")
345
+ print(f" Current directory: {cwd}")
346
+ print(f" Temp run dir exists: {os.path.exists(temp_run_dir)}")
347
+ if os.path.exists(temp_run_dir):
348
+ print(f" Contents: {os.listdir(temp_run_dir)}")
349
+ return None
350
+
351
+ scene_file = os.path.join(temp_scenes_dir, f'SCENE_{scene_idx:04d}_new_{scene_idx:06d}.json')
352
+ if not os.path.exists(scene_file):
353
+ print(f" Scene file not found at expected path: {scene_file}")
354
+ if os.path.exists(temp_scenes_dir):
355
+ scene_files = os.listdir(temp_scenes_dir)
356
+ print(f" Available scene files ({len(scene_files)}): {scene_files[:10]}")
357
+ matching_files = [f for f in scene_files if f.startswith(f'SCENE_{scene_idx:04d}')]
358
+ print(f" Files matching SCENE_{scene_idx:04d}: {matching_files}")
359
+ if matching_files:
360
+ scene_file = os.path.join(temp_scenes_dir, matching_files[0])
361
+ print(f" Using: {scene_file}")
362
+ else:
363
+ print(f"ERROR: No matching scene file found for scene_idx {scene_idx}")
364
+ print(f" Checked for files starting with: SCENE_{scene_idx:04d}")
365
+ return None
366
+ else:
367
+ print(f"ERROR: temp run scenes directory does not exist: {temp_scenes_dir}")
368
+ return None
369
+
370
+ if not os.path.exists(scene_file):
371
+ print(f"ERROR: Scene file not found: {scene_file}")
372
+ return None
373
+
374
+ try:
375
+ with open(scene_file, 'r') as f:
376
+ scene = json.load(f)
377
+ except Exception as e:
378
+ print(f"ERROR: Failed to read scene file {scene_file}: {e}")
379
+ return None
380
+
381
+ if 'objects' not in scene:
382
+ print(f"ERROR: Scene file missing 'objects' key. Scene keys: {list(scene.keys())}")
383
+ print(f" Scene file contents (first 500 chars): {json.dumps(scene, indent=2)[:500]}")
384
+ return None
385
+
386
+ if len(scene['objects']) == 0:
387
+ print(f"WARNING: Scene has 0 objects")
388
+ print(f" This usually means Blender hit max_retries trying to place objects")
389
+ print(f" Scene file contents (first 1000 chars): {json.dumps(scene, indent=2)[:1000]}")
390
+ if result and result.stdout:
391
+ retry_lines = [line for line in result.stdout.split('\n') if 'retry' in line.lower() or 'Retry' in line or 'attempt' in line.lower()]
392
+ if retry_lines:
393
+ print(f" Retry-related output: {retry_lines[-5:]}")
394
+ return None
395
+
396
+ print(f" [OK] Generated scene with {len(scene['objects'])} objects")
397
+ return scene
398
+ finally:
399
+ if temp_dir_to_clean and os.path.exists(temp_dir_to_clean):
400
+ try:
401
+ shutil.rmtree(temp_dir_to_clean)
402
+ except Exception:
403
+ pass
404
+
405
+ def cf_change_color(scene):
406
+ cf_scene = copy.deepcopy(scene)
407
+
408
+ if len(cf_scene['objects']) == 0:
409
+ return cf_scene, "no change (0 objects)"
410
+
411
+ change_idx = random.randint(0, len(cf_scene['objects']) - 1)
412
+ obj = cf_scene['objects'][change_idx]
413
+
414
+ colors = ['gray', 'red', 'blue', 'green', 'brown', 'purple', 'cyan', 'yellow']
415
+ old_color = obj['color']
416
+ new_color = random.choice([c for c in colors if c != old_color])
417
+ obj['color'] = new_color
418
+
419
+ return cf_scene, f"changed {old_color} {obj['shape']} to {new_color}"
420
+
421
+ def cf_change_position(scene):
422
+ cf_scene = copy.deepcopy(scene)
423
+
424
+ if len(cf_scene['objects']) == 0:
425
+ return cf_scene, "no move (0 objects)"
426
+
427
+ move_idx = random.randint(0, len(cf_scene['objects']) - 1)
428
+ obj = cf_scene['objects'][move_idx]
429
+ old_coords = obj['3d_coords']
430
+
431
+ try:
432
+ script_dir = os.path.dirname(os.path.abspath(__file__))
433
+ with open(os.path.join(script_dir, 'data', 'properties.json'), 'r') as f:
434
+ properties = json.load(f)
435
+ size_mapping = properties['sizes']
436
+ except Exception:
437
+ size_mapping = {'small': 0.35, 'large': 0.7}
438
+
439
+ r = size_mapping.get(obj['size'], 0.5)
440
+ if obj['shape'] == 'cube':
441
+ r /= math.sqrt(2)
442
+
443
+ min_dist = 0.25
444
+ for attempt in range(100):
445
+ new_x = random.uniform(-3, 3)
446
+ new_y = random.uniform(-3, 3)
447
+ try:
448
+ dx0 = float(new_x) - float(old_coords[0])
449
+ dy0 = float(new_y) - float(old_coords[1])
450
+ if math.sqrt(dx0 * dx0 + dy0 * dy0) < 1.0:
451
+ continue
452
+ except Exception:
453
+ pass
454
+
455
+ collision = False
456
+ for other_idx, other_obj in enumerate(cf_scene['objects']):
457
+ if other_idx == move_idx:
458
+ continue
459
+ other_x, other_y, _ = other_obj['3d_coords']
460
+ other_r = size_mapping.get(other_obj['size'], 0.5)
461
+ if other_obj['shape'] == 'cube':
462
+ other_r /= math.sqrt(2)
463
+ dist = math.sqrt((new_x - other_x)**2 + (new_y - other_y)**2)
464
+ if dist < (r + other_r + min_dist):
465
+ collision = True
466
+ break
467
+
468
+ if not collision:
469
+ obj['3d_coords'] = [new_x, new_y, old_coords[2]]
470
+ obj['pixel_coords'] = [0, 0, 0]
471
+ return cf_scene, f"moved {obj['color']} {obj['shape']} from ({old_coords[0]:.1f},{old_coords[1]:.1f}) to ({new_x:.1f},{new_y:.1f})"
472
+
473
+ return cf_scene, f"no move (couldn't find collision-free position for {obj['color']} {obj['shape']})"
474
+
475
+ def cf_add_object(scene):
476
+ cf_scene = copy.deepcopy(scene)
477
+
478
+ shapes = ['cube', 'sphere', 'cylinder']
479
+ colors = ['gray', 'red', 'blue', 'green', 'brown', 'purple', 'cyan', 'yellow']
480
+ materials = ['metal', 'rubber']
481
+ sizes = ['small', 'large']
482
+
483
+ try:
484
+ script_dir = os.path.dirname(os.path.abspath(__file__))
485
+ with open(os.path.join(script_dir, 'data', 'properties.json'), 'r') as f:
486
+ properties = json.load(f)
487
+ size_mapping = properties['sizes']
488
+ except Exception:
489
+ size_mapping = {'small': 0.35, 'large': 0.7}
490
+
491
+ min_dist = 0.25
492
+
493
+ for attempt in range(100):
494
+ new_shape = random.choice(shapes)
495
+ new_size = random.choice(sizes)
496
+ new_r = size_mapping[new_size]
497
+ if new_shape == 'cube':
498
+ new_r /= math.sqrt(2)
499
+
500
+ new_x = random.uniform(-3, 3)
501
+ new_y = random.uniform(-3, 3)
502
+
503
+ collision = False
504
+ for other_obj in cf_scene['objects']:
505
+ other_x, other_y, _ = other_obj['3d_coords']
506
+ other_r = size_mapping.get(other_obj['size'], 0.5)
507
+ if other_obj['shape'] == 'cube':
508
+ other_r /= math.sqrt(2)
509
+
510
+ dist = math.sqrt((new_x - other_x)**2 + (new_y - other_y)**2)
511
+ if dist < (new_r + other_r + min_dist):
512
+ collision = True
513
+ break
514
+
515
+ if not collision:
516
+ new_obj = {
517
+ 'shape': new_shape,
518
+ 'color': random.choice(colors),
519
+ 'material': random.choice(materials),
520
+ 'size': new_size,
521
+ '3d_coords': [new_x, new_y, 0.35],
522
+ 'rotation': random.uniform(0, 360),
523
+ 'pixel_coords': [0, 0, 0]
524
+ }
525
+ cf_scene['objects'].append(new_obj)
526
+ return cf_scene, f"added {new_obj['color']} {new_obj['material']} {new_obj['shape']}"
527
+
528
+ return cf_scene, "no change (couldn't find collision-free position for new object)"
529
+
530
+ def cf_remove_object(scene):
531
+ cf_scene = copy.deepcopy(scene)
532
+
533
+ if len(cf_scene['objects']) <= 1:
534
+ return cf_scene, "no removal (1 or fewer objects)"
535
+
536
+ remove_idx = random.randint(0, len(cf_scene['objects']) - 1)
537
+ removed_obj = cf_scene['objects'].pop(remove_idx)
538
+
539
+ return cf_scene, f"removed {removed_obj['color']} {removed_obj['shape']}"
540
+
541
+ def cf_replace_object(scene):
542
+ cf_scene = copy.deepcopy(scene)
543
+
544
+ if len(cf_scene['objects']) == 0:
545
+ return cf_scene, "no replace (0 objects)"
546
+
547
+ replace_idx = random.randint(0, len(cf_scene['objects']) - 1)
548
+ old_obj = cf_scene['objects'][replace_idx]
549
+ old_desc = f"{old_obj['color']} {old_obj['shape']}"
550
+
551
+ shapes = ['cube', 'sphere', 'cylinder']
552
+ colors = ['gray', 'red', 'blue', 'green', 'brown', 'purple', 'cyan', 'yellow']
553
+ materials = ['metal', 'rubber']
554
+ sizes = ['small', 'large']
555
+
556
+ new_shape = random.choice([s for s in shapes if s != old_obj['shape']] or shapes)
557
+ new_color = random.choice([c for c in colors if c != old_obj['color']] or colors)
558
+
559
+ new_obj = {
560
+ 'shape': new_shape,
561
+ 'color': new_color,
562
+ 'material': random.choice(materials),
563
+ 'size': random.choice(sizes),
564
+ '3d_coords': old_obj['3d_coords'],
565
+ 'rotation': random.uniform(0, 360),
566
+ 'pixel_coords': [0, 0, 0]
567
+ }
568
+
569
+ cf_scene['objects'][replace_idx] = new_obj
570
+
571
+ return cf_scene, f"replaced {old_desc} with {new_color} {new_shape}"
572
+
573
+
574
+ def cf_swap_attribute(scene):
575
+ cf_scene = copy.deepcopy(scene)
576
+
577
+ if len(cf_scene['objects']) < 2:
578
+ return cf_scene, "no swap (fewer than 2 objects)"
579
+ # Only consider pairs that differ in at least one intrinsic attribute
580
+ # beyond color, so we avoid swapping attributes between identical
581
+ # (shape,size,material) objects where questions cannot distinguish them.
582
+ candidates = []
583
+ objs = cf_scene['objects']
584
+ n = len(objs)
585
+ for i in range(n):
586
+ for j in range(i + 1, n):
587
+ a = objs[i]
588
+ b = objs[j]
589
+ if a['color'] == b['color']:
590
+ continue
591
+ if a['shape'] == b['shape'] and a['size'] == b['size'] and a['material'] == b['material']:
592
+ continue
593
+ candidates.append((i, j))
594
+ if not candidates:
595
+ return cf_scene, "no swap (no suitable object pair with different shape/size/material)"
596
+ idx_a, idx_b = random.choice(candidates)
597
+ obj_a = objs[idx_a]
598
+ obj_b = objs[idx_b]
599
+ color_a, color_b = obj_a['color'], obj_b['color']
600
+ obj_a['color'] = color_b
601
+ obj_b['color'] = color_a
602
+ return cf_scene, f"swapped colors between {color_a} {obj_a['shape']} and {color_b} {obj_b['shape']}"
603
+
604
+
605
+ TARGET_OCCLUSION_COVERAGE = 0.6
606
+ # Lateral offset as fraction of combined radius: occluder placed to the SIDE of target
607
+ # so only part of target is hidden (partial occlusion), not fully behind occluder.
608
+ OCCLUSION_LATERAL_FRACTIONS = (0.35, 0.5, 0.65, 0.25, 0.45)
609
+
610
+
611
+ def cf_occlusion_change(scene):
612
+ cf_scene = copy.deepcopy(scene)
613
+
614
+ if len(cf_scene['objects']) < 2:
615
+ return cf_scene, "no occlusion (fewer than 2 objects)"
616
+
617
+ directions = cf_scene.get('directions', {})
618
+ front = directions.get('front', [0.75, -0.66, 0.0])
619
+ if len(front) < 2:
620
+ front = [0.75, -0.66]
621
+ left = directions.get('left', [-0.66, -0.75, 0.0])
622
+ if len(left) < 2:
623
+ left = [-0.66, -0.75]
624
+
625
+ try:
626
+ script_dir = os.path.dirname(os.path.abspath(__file__))
627
+ with open(os.path.join(script_dir, 'data', 'properties.json'), 'r') as f:
628
+ properties = json.load(f)
629
+ size_mapping = properties['sizes']
630
+ except Exception:
631
+ size_mapping = {'small': 0.35, 'large': 0.7}
632
+
633
+ def get_radius(obj):
634
+ r = size_mapping.get(obj['size'], 0.5)
635
+ if obj['shape'] == 'cube':
636
+ r /= math.sqrt(2)
637
+ return r
638
+
639
+ min_dist = 0.15
640
+ def is_valid_occlusion_pos(cf_scene, occluder_idx, target_idx, new_x, new_y, occluder_r):
641
+ for i, other in enumerate(cf_scene['objects']):
642
+ if i == occluder_idx:
643
+ continue
644
+ other_x, other_y, _ = other['3d_coords']
645
+ other_r = get_radius(other)
646
+ dist = math.sqrt((new_x - other_x)**2 + (new_y - other_y)**2)
647
+ if dist < (occluder_r + other_r + min_dist):
648
+ return False
649
+ return -2.8 <= new_x <= 2.8 and -2.8 <= new_y <= 2.8
650
+
651
+ fx, fy = float(front[0]), float(front[1])
652
+ fnorm = math.sqrt(fx * fx + fy * fy) or 1.0
653
+ lx, ly = float(left[0]), float(left[1])
654
+ lnorm = math.sqrt(lx * lx + ly * ly) or 1.0
655
+ # Forward offset: occluder between camera and target, but not jammed on top
656
+ forward_base = 0.25
657
+ forward_deltas = [0.0, 0.05, 0.1, 0.15, 0.2]
658
+
659
+ pairs = [(i, j) for i in range(len(cf_scene['objects'])) for j in range(len(cf_scene['objects'])) if i != j]
660
+ random.shuffle(pairs)
661
+
662
+ for occluder_idx, target_idx in pairs:
663
+ occluder = cf_scene['objects'][occluder_idx]
664
+ target = cf_scene['objects'][target_idx]
665
+ tx, ty, tz = target['3d_coords']
666
+ oz = occluder['3d_coords'][2]
667
+ occluder_r = get_radius(occluder)
668
+ target_r = get_radius(target)
669
+ combined = occluder_r + target_r + min_dist
670
+ # Lateral offset so occluder is to the side → partial overlap in camera view
671
+ for lateral_frac in OCCLUSION_LATERAL_FRACTIONS:
672
+ lateral = lateral_frac * combined
673
+ for sign in (1, -1):
674
+ lat_x = (lx / lnorm) * (lateral * sign)
675
+ lat_y = (ly / lnorm) * (lateral * sign)
676
+ for fdelta in forward_deltas:
677
+ forward = forward_base + fdelta
678
+ new_x = tx + (fx / fnorm) * forward + lat_x
679
+ new_y = ty + (fy / fnorm) * forward + lat_y
680
+ if is_valid_occlusion_pos(cf_scene, occluder_idx, target_idx, new_x, new_y, occluder_r):
681
+ occluder['3d_coords'] = [new_x, new_y, oz]
682
+ occluder['pixel_coords'] = [0, 0, 0]
683
+ return cf_scene, f"moved {occluder['color']} {occluder['shape']} to partially occlude {target['color']} {target['shape']}"
684
+
685
+ return cf_scene, "no occlusion (couldn't find valid position)"
686
+
687
+
688
+ def cf_relational_flip(scene):
689
+ cf_scene = copy.deepcopy(scene)
690
+
691
+ if len(cf_scene['objects']) < 2:
692
+ return cf_scene, "no flip (fewer than 2 objects)"
693
+
694
+ directions = cf_scene.get('directions', {})
695
+ left_vec = directions.get('left', [-0.66, -0.75, 0.0])
696
+ if len(left_vec) < 2:
697
+ left_vec = [-0.66, -0.75]
698
+
699
+ try:
700
+ script_dir = os.path.dirname(os.path.abspath(__file__))
701
+ with open(os.path.join(script_dir, 'data', 'properties.json'), 'r') as f:
702
+ properties = json.load(f)
703
+ size_mapping = properties['sizes']
704
+ except Exception:
705
+ size_mapping = {'small': 0.35, 'large': 0.7}
706
+
707
+ def get_radius(obj):
708
+ r = size_mapping.get(obj['size'], 0.5)
709
+ if obj['shape'] == 'cube':
710
+ r /= math.sqrt(2)
711
+ return r
712
+
713
+ def is_valid_pos(cf_scene, a_idx, new_x, new_y, r_a, min_dist=0.12):
714
+ for i, other in enumerate(cf_scene['objects']):
715
+ if i == a_idx:
716
+ continue
717
+ ox, oy, _ = other['3d_coords']
718
+ other_r = get_radius(other)
719
+ dist = math.sqrt((new_x - ox)**2 + (new_y - oy)**2)
720
+ if dist < (r_a + other_r + min_dist):
721
+ return False
722
+ return -2.8 <= new_x <= 2.8 and -2.8 <= new_y <= 2.8
723
+
724
+ relationships = cf_scene.get('relationships', {})
725
+ left_of = relationships.get('left', [])
726
+ right_of = relationships.get('right', [])
727
+
728
+ candidates = []
729
+ for b_idx in range(len(cf_scene['objects'])):
730
+ for a_idx in left_of[b_idx] if b_idx < len(left_of) else []:
731
+ if a_idx != b_idx and a_idx < len(cf_scene['objects']):
732
+ candidates.append((a_idx, b_idx, 'left'))
733
+ for a_idx in right_of[b_idx] if b_idx < len(right_of) else []:
734
+ if a_idx != b_idx and a_idx < len(cf_scene['objects']):
735
+ candidates.append((a_idx, b_idx, 'right'))
736
+
737
+ if not candidates:
738
+ lx, ly = float(left_vec[0]), float(left_vec[1])
739
+ for a_idx in range(len(cf_scene['objects'])):
740
+ for b_idx in range(len(cf_scene['objects'])):
741
+ if a_idx == b_idx:
742
+ continue
743
+ ax_a, ay_a, _ = cf_scene['objects'][a_idx]['3d_coords']
744
+ bx_b, by_b, _ = cf_scene['objects'][b_idx]['3d_coords']
745
+ dx, dy = ax_a - bx_b, ay_a - by_b
746
+ dot = dx * lx + dy * ly
747
+ if abs(dot) > 0.2:
748
+ side = 'left' if dot > 0 else 'right'
749
+ candidates.append((a_idx, b_idx, side))
750
+
751
+ if not candidates:
752
+ return cf_scene, "no flip (no clear left/right relationships)"
753
+
754
+ random.shuffle(candidates)
755
+ lx, ly = float(left_vec[0]), float(left_vec[1])
756
+
757
+ for a_idx, b_idx, side in candidates:
758
+ obj_a = cf_scene['objects'][a_idx]
759
+ obj_b = cf_scene['objects'][b_idx]
760
+ ax, ay, az = obj_a['3d_coords']
761
+ bx, by, bz = obj_b['3d_coords']
762
+ r_a = get_radius(obj_a)
763
+
764
+ dx, dy = ax - bx, ay - by
765
+ dot_left = dx * lx + dy * ly
766
+ ref_dx = dx - 2 * dot_left * lx
767
+ ref_dy = dy - 2 * dot_left * ly
768
+
769
+ for scale in [1.0, 0.9, 0.8, 0.7, 0.85, 0.75]:
770
+ new_x = bx + scale * ref_dx
771
+ new_y = by + scale * ref_dy
772
+ if is_valid_pos(cf_scene, a_idx, new_x, new_y, r_a):
773
+ obj_a['3d_coords'] = [new_x, new_y, az]
774
+ obj_a['pixel_coords'] = [0, 0, 0]
775
+ new_side = "right" if side == "left" else "left"
776
+ return cf_scene, f"moved {obj_a['color']} {obj_a['shape']} from {side} of {obj_b['color']} {obj_b['shape']} to {new_side}"
777
+
778
+ return cf_scene, "no flip (couldn't find collision-free position)"
779
+
780
+
781
+ def cf_change_background(scene):
782
+ cf_scene = copy.deepcopy(scene)
783
+
784
+ background_colors = ['gray', 'blue', 'green', 'brown', 'purple', 'orange', 'white', 'dark_gray']
785
+ current_bg = cf_scene.get('background_color', 'default')
786
+
787
+ alternatives = [c for c in background_colors if c != current_bg]
788
+ new_background = random.choice(alternatives) if alternatives else 'gray'
789
+
790
+ cf_scene['background_color'] = new_background
791
+
792
+ return cf_scene, f"changed background to {new_background}"
793
+
794
+ def cf_change_shape(scene):
795
+ cf_scene = copy.deepcopy(scene)
796
+
797
+ if len(cf_scene['objects']) == 0:
798
+ return cf_scene, "no change (0 objects)"
799
+
800
+ change_idx = random.randint(0, len(cf_scene['objects']) - 1)
801
+ obj = cf_scene['objects'][change_idx]
802
+
803
+ shapes = ['cube', 'sphere', 'cylinder']
804
+ old_shape = obj['shape']
805
+ new_shape = random.choice([s for s in shapes if s != old_shape])
806
+ obj['shape'] = new_shape
807
+
808
+ return cf_scene, f"changed {obj['color']} {old_shape} to {new_shape}"
809
+
810
+ def cf_change_size(scene):
811
+ cf_scene = copy.deepcopy(scene)
812
+
813
+ if len(cf_scene['objects']) == 0:
814
+ return cf_scene, "no change (0 objects)"
815
+
816
+ change_idx = random.randint(0, len(cf_scene['objects']) - 1)
817
+ obj = cf_scene['objects'][change_idx]
818
+
819
+ old_size = obj['size']
820
+ new_size = 'large' if old_size == 'small' else 'small'
821
+ obj['size'] = new_size
822
+
823
+ return cf_scene, f"changed {obj['color']} {obj['shape']} from {old_size} to {new_size}"
824
+
825
+ def cf_change_material(scene):
826
+ cf_scene = copy.deepcopy(scene)
827
+
828
+ if len(cf_scene['objects']) == 0:
829
+ return cf_scene, "no change (0 objects)"
830
+
831
+ change_idx = random.randint(0, len(cf_scene['objects']) - 1)
832
+ obj = cf_scene['objects'][change_idx]
833
+
834
+ old_material = obj['material']
835
+ new_material = 'rubber' if old_material == 'metal' else 'metal'
836
+ obj['material'] = new_material
837
+
838
+ return cf_scene, f"changed {obj['color']} {obj['shape']} from {old_material} to {new_material}"
839
+
840
+ def cf_change_lighting(scene):
841
+ cf_scene = copy.deepcopy(scene)
842
+
843
+ lighting_conditions = ['bright', 'dim', 'warm', 'cool', 'dramatic']
844
+ current_lighting = cf_scene.get('lighting', 'default')
845
+
846
+ alternatives = [l for l in lighting_conditions if l != current_lighting]
847
+ new_lighting = random.choice(alternatives) if alternatives else 'bright'
848
+
849
+ cf_scene['lighting'] = new_lighting
850
+
851
+ return cf_scene, f"changed lighting to {new_lighting}"
852
+
853
+ def cf_add_noise(scene, min_noise_level='light'):
854
+ cf_scene = copy.deepcopy(scene)
855
+
856
+ noise_levels = ['light', 'medium', 'heavy']
857
+ noise_weights = {'light': 0, 'medium': 1, 'heavy': 2}
858
+
859
+ min_weight = noise_weights.get(min_noise_level, 0)
860
+ valid_levels = [n for n in noise_levels if noise_weights[n] >= min_weight]
861
+
862
+ current = cf_scene.get('noise_level', None)
863
+ choices = [n for n in valid_levels if n != current] if current in valid_levels else valid_levels
864
+
865
+ if not choices:
866
+ choices = [min_noise_level] if min_noise_level in noise_levels else ['medium']
867
+
868
+ cf_scene['noise_level'] = random.choice(choices)
869
+
870
+ return cf_scene, f"added {cf_scene['noise_level']} noise (min: {min_noise_level})"
871
+
872
+ def cf_apply_fisheye(scene):
873
+ cf_scene = copy.deepcopy(scene)
874
+
875
+ current_filter = cf_scene.get('filter_type', None)
876
+ if current_filter == 'fisheye':
877
+ filter_strength = random.uniform(0.8, 1.2)
878
+ else:
879
+ filter_strength = random.uniform(0.8, 1.2)
880
+
881
+ cf_scene['filter_type'] = 'fisheye'
882
+ cf_scene['filter_strength'] = filter_strength
883
+
884
+ return cf_scene, f"applied fisheye filter (strength: {filter_strength:.2f})"
885
+
886
+ def cf_apply_blur(scene):
887
+ cf_scene = copy.deepcopy(scene)
888
+
889
+ current_filter = cf_scene.get('filter_type', None)
890
+ if current_filter == 'blur':
891
+ filter_strength = random.uniform(8.0, 15.0)
892
+ else:
893
+ filter_strength = random.uniform(8.0, 15.0)
894
+
895
+ cf_scene['filter_type'] = 'blur'
896
+ cf_scene['filter_strength'] = filter_strength
897
+
898
+ return cf_scene, f"applied blur filter (strength: {filter_strength:.2f})"
899
+
900
+ def cf_apply_vignette(scene):
901
+ cf_scene = copy.deepcopy(scene)
902
+
903
+ current_filter = cf_scene.get('filter_type', None)
904
+ if current_filter == 'vignette':
905
+ filter_strength = random.uniform(3.0, 5.0)
906
+ else:
907
+ filter_strength = random.uniform(3.0, 5.0)
908
+
909
+ cf_scene['filter_type'] = 'vignette'
910
+ cf_scene['filter_strength'] = filter_strength
911
+
912
+ return cf_scene, f"applied vignette filter (strength: {filter_strength:.2f})"
913
+
914
+ def cf_apply_chromatic_aberration(scene):
915
+ cf_scene = copy.deepcopy(scene)
916
+
917
+ current_filter = cf_scene.get('filter_type', None)
918
+ if current_filter == 'chromatic_aberration':
919
+ filter_strength = random.uniform(1.0, 4.0)
920
+ else:
921
+ filter_strength = random.uniform(1.0, 4.0)
922
+
923
+ cf_scene['filter_type'] = 'chromatic_aberration'
924
+ cf_scene['filter_strength'] = filter_strength
925
+
926
+ return cf_scene, f"applied chromatic aberration filter (strength: {filter_strength:.2f})"
927
+
928
+ IMAGE_COUNTERFACTUALS = {
929
+ 'change_color': cf_change_color,
930
+ 'change_shape': cf_change_shape,
931
+ 'change_size': cf_change_size,
932
+ 'change_material': cf_change_material,
933
+ 'change_position': cf_change_position,
934
+ 'add_object': cf_add_object,
935
+ 'remove_object': cf_remove_object,
936
+ 'replace_object': cf_replace_object,
937
+ 'swap_attribute': cf_swap_attribute,
938
+ 'relational_flip': cf_relational_flip,
939
+ }
940
+
941
+ NEGATIVE_COUNTERFACTUALS = {
942
+ 'change_background': cf_change_background,
943
+ 'change_lighting': cf_change_lighting,
944
+ 'add_noise': cf_add_noise,
945
+ 'apply_fisheye': cf_apply_fisheye,
946
+ 'apply_blur': cf_apply_blur,
947
+ 'apply_vignette': cf_apply_vignette,
948
+ 'apply_chromatic_aberration': cf_apply_chromatic_aberration,
949
+ 'occlusion_change': cf_occlusion_change,
950
+ }
951
+
952
+ DEFAULT_NEGATIVE_CF_TYPES = [k for k in NEGATIVE_COUNTERFACTUALS if k != 'apply_fisheye']
953
+
954
+ COUNTERFACTUAL_TYPES = {**IMAGE_COUNTERFACTUALS, **NEGATIVE_COUNTERFACTUALS}
955
+
956
+ def generate_counterfactuals(scene, num_counterfactuals=2, cf_types=None, same_cf_type=False, min_change_score=1.0, max_cf_attempts=10, min_noise_level='light', semantic_only=False, negative_only=False):
957
+ counterfactuals = []
958
+
959
+ def compute_change_score(original_scene, cf_scene):
960
+ score = 0.0
961
+ if not isinstance(original_scene, dict) or not isinstance(cf_scene, dict):
962
+ return score
963
+
964
+ orig_objs = original_scene.get('objects', []) or []
965
+ cf_objs = cf_scene.get('objects', []) or []
966
+
967
+ if len(orig_objs) != len(cf_objs):
968
+ score += 5.0
969
+
970
+ n = min(len(orig_objs), len(cf_objs))
971
+ for i in range(n):
972
+ o = orig_objs[i] or {}
973
+ c = cf_objs[i] or {}
974
+ for k in ['color', 'shape', 'size', 'material']:
975
+ if o.get(k) != c.get(k):
976
+ score += 1.0
977
+
978
+ o3 = o.get('3d_coords')
979
+ c3 = c.get('3d_coords')
980
+ try:
981
+ if o3 and c3 and len(o3) >= 2 and len(c3) >= 2:
982
+ dx = float(c3[0]) - float(o3[0])
983
+ dy = float(c3[1]) - float(o3[1])
984
+ dist = math.sqrt(dx * dx + dy * dy)
985
+ score += min(dist, 3.0)
986
+ except Exception:
987
+ pass
988
+
989
+ try:
990
+ if o.get('rotation') is not None and c.get('rotation') is not None:
991
+ rot_diff = abs(float(c.get('rotation')) - float(o.get('rotation')))
992
+ rot_diff = min(rot_diff, 360.0 - rot_diff)
993
+ score += min(rot_diff / 180.0, 1.0) * 0.5
994
+ except Exception:
995
+ pass
996
+
997
+ if 'background_color' in cf_scene and original_scene.get('background_color') != cf_scene.get('background_color'):
998
+ score += 1.0
999
+ if cf_scene.get('background_color') in ['white', 'dark_gray']:
1000
+ score += 0.5
1001
+ if 'lighting' in cf_scene and original_scene.get('lighting') != cf_scene.get('lighting'):
1002
+ score += 1.0
1003
+ if 'noise_level' in cf_scene and original_scene.get('noise_level') != cf_scene.get('noise_level'):
1004
+ score += {'light': 0.5, 'medium': 1.0, 'heavy': 1.5}.get(cf_scene.get('noise_level'), 0.8)
1005
+ if 'filter_type' in cf_scene and original_scene.get('filter_type') != cf_scene.get('filter_type'):
1006
+ filter_strength = cf_scene.get('filter_strength', 1.0)
1007
+ score += min(filter_strength, 2.0)
1008
+
1009
+ return score
1010
+
1011
+ def generate_one_with_min_change(cf_func, min_change_score=0.0, max_attempts=10):
1012
+ best_scene = None
1013
+ best_desc = None
1014
+ best_score = -1.0
1015
+ attempts = 0
1016
+ for attempt in range(max_attempts):
1017
+ attempts = attempt + 1
1018
+ if cf_func == cf_add_noise:
1019
+ candidate_scene, candidate_desc = cf_func(scene, min_noise_level=min_noise_level)
1020
+ else:
1021
+ candidate_scene, candidate_desc = cf_func(scene)
1022
+ score = compute_change_score(scene, candidate_scene)
1023
+ if score > best_score:
1024
+ best_scene, best_desc, best_score = candidate_scene, candidate_desc, score
1025
+ if score >= min_change_score:
1026
+ break
1027
+ return best_scene, best_desc, best_score, attempts
1028
+
1029
+ selected_types = None
1030
+ if semantic_only and negative_only:
1031
+ semantic_only = False
1032
+ negative_only = False
1033
+ if same_cf_type and cf_types:
1034
+ pool = cf_types
1035
+ if semantic_only:
1036
+ pool = [t for t in cf_types if t in IMAGE_COUNTERFACTUALS]
1037
+ elif negative_only:
1038
+ pool = [t for t in cf_types if t in NEGATIVE_COUNTERFACTUALS]
1039
+ if pool:
1040
+ selected_types = [pool[0]] * num_counterfactuals
1041
+ else:
1042
+ selected_types = [cf_types[0]] * num_counterfactuals if cf_types else None
1043
+ elif same_cf_type and not cf_types:
1044
+ if semantic_only:
1045
+ one_type = random.choice(list(IMAGE_COUNTERFACTUALS.keys()))
1046
+ elif negative_only:
1047
+ one_type = random.choice(DEFAULT_NEGATIVE_CF_TYPES)
1048
+ else:
1049
+ one_type = random.choice(list(COUNTERFACTUAL_TYPES.keys()))
1050
+ selected_types = [one_type] * num_counterfactuals
1051
+ elif cf_types:
1052
+ pool = cf_types
1053
+ if semantic_only:
1054
+ pool = [t for t in cf_types if t in IMAGE_COUNTERFACTUALS] or cf_types
1055
+ elif negative_only:
1056
+ pool = [t for t in cf_types if t in NEGATIVE_COUNTERFACTUALS] or cf_types
1057
+ # No duplicate CF types per scene: sample without replacement
1058
+ n = min(num_counterfactuals, len(pool))
1059
+ selected_types = random.sample(pool, n) if n > 0 else []
1060
+ elif semantic_only:
1061
+ pool = list(IMAGE_COUNTERFACTUALS.keys())
1062
+ selected_types = [random.choice(pool) for _ in range(num_counterfactuals)]
1063
+ elif negative_only:
1064
+ pool = DEFAULT_NEGATIVE_CF_TYPES
1065
+ selected_types = [random.choice(pool) for _ in range(num_counterfactuals)]
1066
+
1067
+ if selected_types is not None:
1068
+ for cf_type in selected_types:
1069
+ if cf_type not in COUNTERFACTUAL_TYPES:
1070
+ print(f"WARNING: Unknown CF type '{cf_type}', skipping")
1071
+ continue
1072
+
1073
+ cf_func = COUNTERFACTUAL_TYPES[cf_type]
1074
+ cf_scene, cf_desc, change_score, change_attempts = generate_one_with_min_change(
1075
+ cf_func,
1076
+ min_change_score=min_change_score,
1077
+ max_attempts=max_cf_attempts
1078
+ )
1079
+ cf_category = 'image_cf' if cf_type in IMAGE_COUNTERFACTUALS else 'negative_cf'
1080
+
1081
+ counterfactuals.append({
1082
+ 'scene': cf_scene,
1083
+ 'description': cf_desc,
1084
+ 'type': cf_type,
1085
+ 'cf_category': cf_category,
1086
+ 'change_score': change_score,
1087
+ 'change_attempts': change_attempts,
1088
+ })
1089
+
1090
+ elif num_counterfactuals >= 2:
1091
+ if semantic_only:
1092
+ pool = list(IMAGE_COUNTERFACTUALS.keys())
1093
+ for _ in range(num_counterfactuals):
1094
+ cf_type = random.choice(pool)
1095
+ cf_func = IMAGE_COUNTERFACTUALS[cf_type]
1096
+ cf_scene, cf_desc, change_score, change_attempts = generate_one_with_min_change(
1097
+ cf_func,
1098
+ min_change_score=min_change_score,
1099
+ max_attempts=max_cf_attempts
1100
+ )
1101
+ counterfactuals.append({
1102
+ 'scene': cf_scene,
1103
+ 'description': cf_desc,
1104
+ 'type': cf_type,
1105
+ 'cf_category': 'image_cf',
1106
+ 'change_score': change_score,
1107
+ 'change_attempts': change_attempts,
1108
+ })
1109
+ elif negative_only:
1110
+ pool = DEFAULT_NEGATIVE_CF_TYPES
1111
+ for _ in range(num_counterfactuals):
1112
+ cf_type = random.choice(pool)
1113
+ cf_func = NEGATIVE_COUNTERFACTUALS[cf_type]
1114
+ cf_scene, cf_desc, change_score, change_attempts = generate_one_with_min_change(
1115
+ cf_func,
1116
+ min_change_score=min_change_score,
1117
+ max_attempts=max_cf_attempts
1118
+ )
1119
+ counterfactuals.append({
1120
+ 'scene': cf_scene,
1121
+ 'description': cf_desc,
1122
+ 'type': cf_type,
1123
+ 'cf_category': 'negative_cf',
1124
+ 'change_score': change_score,
1125
+ 'change_attempts': change_attempts,
1126
+ })
1127
+ else:
1128
+ image_cf_type = random.choice(list(IMAGE_COUNTERFACTUALS.keys()))
1129
+ image_cf_func = IMAGE_COUNTERFACTUALS[image_cf_type]
1130
+ cf_scene, cf_desc, change_score, change_attempts = generate_one_with_min_change(
1131
+ image_cf_func,
1132
+ min_change_score=min_change_score,
1133
+ max_attempts=max_cf_attempts
1134
+ )
1135
+ counterfactuals.append({
1136
+ 'scene': cf_scene,
1137
+ 'description': cf_desc,
1138
+ 'type': image_cf_type,
1139
+ 'cf_category': 'image_cf',
1140
+ 'change_score': change_score,
1141
+ 'change_attempts': change_attempts,
1142
+ })
1143
+
1144
+ neg_cf_type = random.choice(DEFAULT_NEGATIVE_CF_TYPES)
1145
+ neg_cf_func = NEGATIVE_COUNTERFACTUALS[neg_cf_type]
1146
+ cf_scene, cf_desc, change_score, change_attempts = generate_one_with_min_change(
1147
+ neg_cf_func,
1148
+ min_change_score=min_change_score,
1149
+ max_attempts=max_cf_attempts
1150
+ )
1151
+ counterfactuals.append({
1152
+ 'scene': cf_scene,
1153
+ 'description': cf_desc,
1154
+ 'type': neg_cf_type,
1155
+ 'cf_category': 'negative_cf',
1156
+ 'change_score': change_score,
1157
+ 'change_attempts': change_attempts,
1158
+ })
1159
+
1160
+ remaining = num_counterfactuals - 2
1161
+ if remaining > 0:
1162
+ all_cf_types = list(COUNTERFACTUAL_TYPES.keys())
1163
+ used_types = {image_cf_type, neg_cf_type}
1164
+ available = [t for t in all_cf_types if t not in used_types and t != 'apply_fisheye']
1165
+
1166
+ extra_types = random.sample(available, min(remaining, len(available)))
1167
+ for cf_type in extra_types:
1168
+ cf_func = COUNTERFACTUAL_TYPES[cf_type]
1169
+ cf_scene, cf_desc, change_score, change_attempts = generate_one_with_min_change(
1170
+ cf_func,
1171
+ min_change_score=min_change_score,
1172
+ max_attempts=max_cf_attempts
1173
+ )
1174
+ cf_category = 'image_cf' if cf_type in IMAGE_COUNTERFACTUALS else 'negative_cf'
1175
+ counterfactuals.append({
1176
+ 'scene': cf_scene,
1177
+ 'description': cf_desc,
1178
+ 'type': cf_type,
1179
+ 'cf_category': cf_category,
1180
+ 'change_score': change_score,
1181
+ 'change_attempts': change_attempts,
1182
+ })
1183
+
1184
+ elif num_counterfactuals == 1:
1185
+ if semantic_only:
1186
+ cf_type = random.choice(list(IMAGE_COUNTERFACTUALS.keys()))
1187
+ cf_func = IMAGE_COUNTERFACTUALS[cf_type]
1188
+ cf_category = 'image_cf'
1189
+ elif negative_only:
1190
+ cf_type = random.choice(DEFAULT_NEGATIVE_CF_TYPES)
1191
+ cf_func = NEGATIVE_COUNTERFACTUALS[cf_type]
1192
+ cf_category = 'negative_cf'
1193
+ elif random.random() < 0.5:
1194
+ cf_type = random.choice(list(IMAGE_COUNTERFACTUALS.keys()))
1195
+ cf_func = IMAGE_COUNTERFACTUALS[cf_type]
1196
+ cf_category = 'image_cf'
1197
+ else:
1198
+ cf_type = random.choice(DEFAULT_NEGATIVE_CF_TYPES)
1199
+ cf_func = NEGATIVE_COUNTERFACTUALS[cf_type]
1200
+ cf_category = 'negative_cf'
1201
+
1202
+ cf_scene, cf_desc, change_score, change_attempts = generate_one_with_min_change(
1203
+ cf_func,
1204
+ min_change_score=min_change_score,
1205
+ max_attempts=max_cf_attempts
1206
+ )
1207
+ counterfactuals.append({
1208
+ 'scene': cf_scene,
1209
+ 'description': cf_desc,
1210
+ 'type': cf_type,
1211
+ 'cf_category': cf_category,
1212
+ 'change_score': change_score,
1213
+ 'change_attempts': change_attempts,
1214
+ })
1215
+
1216
+ return counterfactuals
1217
+
1218
+ def render_scene(blender_path, scene_file, output_image, use_gpu=0, samples=512, width=320, height=240):
1219
+ current_dir = os.path.dirname(os.path.abspath(__file__))
1220
+ render_script = os.path.join(current_dir, 'scripts', 'render.py')
1221
+ render_script = os.path.normpath(render_script)
1222
+
1223
+ base_scene = os.path.join(current_dir, 'data', 'base_scene.blend')
1224
+ properties_json = os.path.join(current_dir, 'data', 'properties.json')
1225
+ shape_dir = os.path.join(current_dir, 'data', 'shapes')
1226
+ material_dir = os.path.join(current_dir, 'data', 'materials')
1227
+
1228
+ base_scene = os.path.normpath(base_scene)
1229
+ properties_json = os.path.normpath(properties_json)
1230
+ shape_dir = os.path.normpath(shape_dir)
1231
+ material_dir = os.path.normpath(material_dir)
1232
+ scene_file = os.path.normpath(scene_file)
1233
+ output_image = os.path.normpath(output_image)
1234
+
1235
+ if not os.path.exists(base_scene):
1236
+ print(f" ERROR: Base scene file not found: {base_scene}")
1237
+ return False
1238
+ if not os.path.exists(properties_json):
1239
+ print(f" ERROR: Properties JSON not found: {properties_json}")
1240
+ return False
1241
+ if not os.path.exists(scene_file):
1242
+ print(f" ERROR: Scene file not found: {scene_file}")
1243
+ return False
1244
+
1245
+ output_dir = os.path.dirname(output_image)
1246
+ if output_dir and not os.path.exists(output_dir):
1247
+ os.makedirs(output_dir, exist_ok=True)
1248
+
1249
+ output_image_abs = os.path.abspath(output_image)
1250
+
1251
+ cmd = [
1252
+ blender_path, '--background', '-noaudio', '--python', render_script, '--',
1253
+ '--scene_file', scene_file,
1254
+ '--output_image', output_image_abs,
1255
+ '--base_scene_blendfile', base_scene,
1256
+ '--properties_json', properties_json,
1257
+ '--shape_dir', shape_dir,
1258
+ '--material_dir', material_dir,
1259
+ '--use_gpu', str(use_gpu),
1260
+ '--render_num_samples', str(samples),
1261
+ '--width', str(width),
1262
+ '--height', str(height)
1263
+ ]
1264
+
1265
+ env = os.environ.copy()
1266
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=600, env=env)
1267
+
1268
+ if result.returncode == 0 and os.path.exists(output_image_abs):
1269
+ try:
1270
+ import json
1271
+ with open(scene_file, 'r') as f:
1272
+ scene_data = json.load(f)
1273
+
1274
+ filter_type = scene_data.get('filter_type')
1275
+ noise_level = scene_data.get('noise_level')
1276
+
1277
+ if filter_type or noise_level:
1278
+ try:
1279
+ from PIL import Image, ImageFilter
1280
+ import math
1281
+
1282
+ filter_strength = scene_data.get('filter_strength', 1.0)
1283
+ img = Image.open(output_image_abs)
1284
+
1285
+ if filter_type == 'blur':
1286
+ radius = max(1, int(filter_strength))
1287
+ img = img.filter(ImageFilter.GaussianBlur(radius=radius))
1288
+
1289
+ elif filter_type == 'vignette':
1290
+ width, height = img.size
1291
+ center_x, center_y = width // 2, height // 2
1292
+ max_dist = math.sqrt(center_x**2 + center_y**2)
1293
+ img = img.convert('RGB')
1294
+ pixels = img.load()
1295
+ for y in range(height):
1296
+ for x in range(width):
1297
+ dist = math.sqrt((x - center_x)**2 + (y - center_y)**2)
1298
+ factor = 1.0 - (dist / max_dist) * (filter_strength / 5.0)
1299
+ factor = max(0.0, min(1.0, factor))
1300
+ r, g, b = pixels[x, y]
1301
+ pixels[x, y] = (int(r * factor), int(g * factor), int(b * factor))
1302
+
1303
+ elif filter_type == 'fisheye':
1304
+ width, height = img.size
1305
+ center_x, center_y = width / 2.0, height / 2.0
1306
+ max_radius = min(center_x, center_y)
1307
+ img = img.convert('RGB')
1308
+ output = Image.new('RGB', (width, height))
1309
+ out_pixels = output.load()
1310
+ in_pixels = img.load()
1311
+
1312
+ distortion_strength = 0.55 * filter_strength
1313
+
1314
+ for y in range(height):
1315
+ for x in range(width):
1316
+ dx = (x - center_x) / max_radius
1317
+ dy = (y - center_y) / max_radius
1318
+ distance = math.sqrt(dx*dx + dy*dy)
1319
+
1320
+ if distance >= 1.0:
1321
+ out_pixels[x, y] = in_pixels[x, y]
1322
+ else:
1323
+ theta = math.atan2(dy, dx)
1324
+ r_normalized = distance
1325
+
1326
+ r_distorted = math.atan(2 * r_normalized * distortion_strength) / math.atan(2 * distortion_strength)
1327
+ r_distorted = min(0.999, r_distorted)
1328
+
1329
+ src_x = int(center_x + r_distorted * max_radius * math.cos(theta))
1330
+ src_y = int(center_y + r_distorted * max_radius * math.sin(theta))
1331
+
1332
+ if 0 <= src_x < width and 0 <= src_y < height:
1333
+ out_pixels[x, y] = in_pixels[src_x, src_y]
1334
+ else:
1335
+ out_pixels[x, y] = in_pixels[x, y]
1336
+
1337
+ img = output
1338
+
1339
+ elif filter_type == 'chromatic_aberration':
1340
+ width, height = img.size
1341
+ img = img.convert('RGB')
1342
+ output = Image.new('RGB', (width, height))
1343
+ out_pixels = output.load()
1344
+ in_pixels = img.load()
1345
+
1346
+ offset = int(filter_strength * 2)
1347
+
1348
+ for y in range(height):
1349
+ for x in range(width):
1350
+ r_x = max(0, min(width - 1, x - offset))
1351
+ g_x = x
1352
+ b_x = max(0, min(width - 1, x + offset))
1353
+
1354
+ r = in_pixels[r_x, y][0] if 0 <= r_x < width else 0
1355
+ g = in_pixels[g_x, y][1] if 0 <= g_x < width else 0
1356
+ b = in_pixels[b_x, y][2] if 0 <= b_x < width else 0
1357
+
1358
+ out_pixels[x, y] = (r, g, b)
1359
+
1360
+ img = output
1361
+
1362
+ if noise_level:
1363
+ import random
1364
+ noise_amounts = {'light': 10, 'medium': 25, 'heavy': 50}
1365
+ noise_amount = noise_amounts.get(noise_level, 20)
1366
+ img = img.convert('RGB')
1367
+ pixels = img.load()
1368
+ width, height = img.size
1369
+
1370
+ for y in range(height):
1371
+ for x in range(width):
1372
+ r, g, b = pixels[x, y]
1373
+ noise_r = random.randint(-noise_amount, noise_amount)
1374
+ noise_g = random.randint(-noise_amount, noise_amount)
1375
+ noise_b = random.randint(-noise_amount, noise_amount)
1376
+
1377
+ r = max(0, min(255, r + noise_r))
1378
+ g = max(0, min(255, g + noise_g))
1379
+ b = max(0, min(255, b + noise_b))
1380
+
1381
+ pixels[x, y] = (r, g, b)
1382
+
1383
+ img.save(output_image_abs)
1384
+ except ImportError:
1385
+ pass
1386
+ except Exception as e:
1387
+ print(f" Warning: Could not apply filter {filter_type}: {e}")
1388
+
1389
+ except Exception as e:
1390
+ pass
1391
+
1392
+ if result.returncode == 0 and os.path.exists(output_image_abs):
1393
+ return True
1394
+ else:
1395
+ print(f" ERROR rendering {output_image_abs}")
1396
+ print(f" Return code: {result.returncode}")
1397
+ print(f" Output file exists: {os.path.exists(output_image_abs)}")
1398
+ if result.stderr:
1399
+ print(f" Blender stderr (last 1000 chars):")
1400
+ print(result.stderr[-1000:])
1401
+ if result.stdout:
1402
+ print(f" Blender stdout (last 1000 chars):")
1403
+ print(result.stdout[-1000:])
1404
+ error_lines = [line for line in result.stdout.split('\n') if 'ERROR' in line.upper() or 'Traceback' in line or 'Exception' in line or 'failed' in line.lower()]
1405
+ if error_lines:
1406
+ error_msg = '\n'.join(error_lines[-20:])
1407
+ print(f" Error lines found: {error_msg}")
1408
+ return False
1409
+
1410
+ def save_scene(scene, output_path):
1411
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
1412
+ with open(output_path, 'w') as f:
1413
+ json.dump(scene, f, indent=2)
1414
+
1415
+ def list_counterfactual_types():
1416
+ print("\n" + "="*70)
1417
+ print("AVAILABLE COUNTERFACTUAL TYPES")
1418
+ print("="*70)
1419
+
1420
+ print("\nIMAGE COUNTERFACTUALS (Should change VQA answers):")
1421
+ print(" change_color - Change color of an object")
1422
+ print(" change_shape - Change shape of an object (cube/sphere/cylinder)")
1423
+ print(" change_size - Change size of an object (small/large)")
1424
+ print(" change_material - Change material of an object (metal/rubber)")
1425
+ print(" change_position - Move an object to a different location")
1426
+ print(" add_object - Add a new random object")
1427
+ print(" swap_attribute - Swap colors between two objects")
1428
+ print(" relational_flip - Move object from left of X to right of X")
1429
+ print(" remove_object - Remove a random object")
1430
+ print(" replace_object - Replace an object with a different one")
1431
+
1432
+ print("\nNEGATIVE COUNTERFACTUALS (Should NOT change VQA answers):")
1433
+ print(" change_background - Change background/ground color")
1434
+ print(" change_lighting - Change lighting conditions")
1435
+ print(" add_noise - Add image noise/grain")
1436
+ print(" occlusion_change - Move object to partially hide another")
1437
+ print(" apply_fisheye - Apply fisheye lens distortion")
1438
+ print(" apply_blur - Apply Gaussian blur")
1439
+ print(" apply_vignette - Apply vignette (edge darkening)")
1440
+ print(" apply_chromatic_aberration - Apply chromatic aberration (color fringing)")
1441
+
1442
+ print("\n" + "="*70)
1443
+ print("\nUsage examples:")
1444
+ print(" # Use specific types")
1445
+ print(" python pipeline.py --num_scenes 10 --cf_types change_color change_position")
1446
+ print(" ")
1447
+ print(" # Mix image and negative CFs")
1448
+ print(" python pipeline.py --num_scenes 10 --cf_types change_shape change_lighting")
1449
+ print(" ")
1450
+ print(" # Only negative CFs")
1451
+ print(" python pipeline.py --num_scenes 10 --cf_types change_background add_noise")
1452
+ print("="*70 + "\n")
1453
+
1454
+ def find_scene_files(scenes_dir):
1455
+ scene_files = glob.glob(os.path.join(scenes_dir, 'scene_*.json'))
1456
+ scene_sets = {}
1457
+
1458
+ for scene_file in scene_files:
1459
+ basename = os.path.basename(scene_file)
1460
+ parts = basename.replace('.json', '').split('_')
1461
+ if len(parts) >= 3:
1462
+ scene_num = int(parts[1])
1463
+ variant = '_'.join(parts[2:])
1464
+
1465
+ if scene_num not in scene_sets:
1466
+ scene_sets[scene_num] = {}
1467
+ scene_sets[scene_num][variant] = scene_file
1468
+
1469
+ return scene_sets
1470
+
1471
+ def render_existing_scenes(args):
1472
+ import json
1473
+
1474
+ if args.run_dir:
1475
+ run_dir = args.run_dir
1476
+ elif args.run_name:
1477
+ run_dir = os.path.join(args.output_dir, args.run_name)
1478
+ elif args.auto_latest:
1479
+ if not os.path.exists(args.output_dir):
1480
+ print(f"ERROR: Output directory does not exist: {args.output_dir}")
1481
+ return
1482
+
1483
+ subdirs = [d for d in os.listdir(args.output_dir)
1484
+ if os.path.isdir(os.path.join(args.output_dir, d))]
1485
+ if not subdirs:
1486
+ print(f"ERROR: No run directories found in {args.output_dir}")
1487
+ return
1488
+
1489
+ dirs_with_time = [(d, os.path.getmtime(os.path.join(args.output_dir, d)))
1490
+ for d in subdirs]
1491
+ latest = max(dirs_with_time, key=lambda x: x[1])[0]
1492
+ run_dir = os.path.join(args.output_dir, latest)
1493
+ print(f"Using latest run: {run_dir}")
1494
+ else:
1495
+ print("ERROR: Must specify --run_dir, --run_name, or --auto_latest for --render_only")
1496
+ return
1497
+
1498
+ if not os.path.exists(run_dir):
1499
+ print(f"ERROR: Run directory does not exist: {run_dir}")
1500
+ return
1501
+
1502
+ scenes_dir = os.path.join(run_dir, 'scenes')
1503
+ images_dir = os.path.join(run_dir, 'images')
1504
+
1505
+ if not os.path.exists(scenes_dir):
1506
+ print(f"ERROR: Scenes directory does not exist: {scenes_dir}")
1507
+ return
1508
+
1509
+ os.makedirs(images_dir, exist_ok=True)
1510
+
1511
+ blender_path = args.blender_path or find_blender()
1512
+ print(f"Using Blender: {blender_path}")
1513
+
1514
+ print("\nPreparing render scripts...")
1515
+ create_patched_render_script()
1516
+
1517
+ scene_sets = find_scene_files(scenes_dir)
1518
+ total_scenes = len(scene_sets)
1519
+
1520
+ if total_scenes == 0:
1521
+ print(f"ERROR: No scene JSON files found in {scenes_dir}")
1522
+ return
1523
+
1524
+ print(f"\nFound {total_scenes} scene sets to render")
1525
+
1526
+ checkpoint_file = os.path.join(run_dir, 'render_checkpoint.json')
1527
+
1528
+ completed_scenes = set()
1529
+ if args.resume:
1530
+ completed_scenes = load_checkpoint(checkpoint_file)
1531
+ rendered_scenes = get_completed_scenes_from_folder(images_dir)
1532
+ completed_scenes.update(rendered_scenes)
1533
+
1534
+ if completed_scenes:
1535
+ print(f"\n[RESUME] Found {len(completed_scenes)} already rendered scenes")
1536
+
1537
+ print("\n" + "="*70)
1538
+ print(f"RENDERING {total_scenes} SCENE SETS")
1539
+ print("="*70)
1540
+
1541
+ successful_renders = 0
1542
+
1543
+ for scene_num in sorted(scene_sets.keys()):
1544
+ if scene_num in completed_scenes:
1545
+ print(f"\n[SKIP] Skipping scene {scene_num} (already rendered)")
1546
+ successful_renders += 1
1547
+ continue
1548
+
1549
+ print(f"\n{'='*70}")
1550
+ print(f"SCENE SET {scene_num+1}/{total_scenes} (Scene #{scene_num})")
1551
+ print(f"{'='*70}")
1552
+
1553
+ scene_set = scene_sets[scene_num]
1554
+ total_to_render = len(scene_set)
1555
+ render_success = 0
1556
+
1557
+ for variant, scene_file in scene_set.items():
1558
+ scene_prefix = f"scene_{scene_num:04d}"
1559
+ image_file = os.path.join(images_dir, f"{scene_prefix}_{variant}.png")
1560
+
1561
+ print(f" Rendering {variant}...")
1562
+ if render_scene(blender_path, scene_file, image_file,
1563
+ args.use_gpu, args.samples, args.width, args.height):
1564
+ render_success += 1
1565
+ print(f" [OK] {variant}")
1566
+ else:
1567
+ print(f" [FAILED] {variant}")
1568
+
1569
+ if render_success == total_to_render:
1570
+ successful_renders += 1
1571
+ completed_scenes.add(scene_num)
1572
+ save_checkpoint(checkpoint_file, list(completed_scenes))
1573
+
1574
+ print(f" [OK] Rendered {render_success}/{total_to_render} images")
1575
+
1576
+ metadata_path = os.path.join(run_dir, 'run_metadata.json')
1577
+ if os.path.exists(metadata_path):
1578
+ with open(metadata_path, 'r') as f:
1579
+ metadata = json.load(f)
1580
+ metadata['successful_renders'] = successful_renders
1581
+ metadata['rendered_timestamp'] = datetime.now().isoformat()
1582
+ with open(metadata_path, 'w') as f:
1583
+ json.dump(metadata, f, indent=2)
1584
+
1585
+ print("\n" + "="*70)
1586
+ print("RENDERING COMPLETE")
1587
+ print("="*70)
1588
+ print(f"Run directory: {run_dir}")
1589
+ print(f"Successfully rendered: {successful_renders}/{total_scenes} scene sets")
1590
+ print(f"\nOutput:")
1591
+ print(f" Images: {images_dir}/")
1592
+ print(f" Checkpoint: {checkpoint_file}")
1593
+ print("="*70)
1594
+
1595
+ def is_running_on_huggingface():
1596
+ return (
1597
+ os.environ.get('SPACES_REPO_TYPE') is not None or
1598
+ os.environ.get('SPACES_REPO_ID') is not None or
1599
+ os.environ.get('SPACE_ID') is not None or
1600
+ os.environ.get('HF_HOME') is not None or
1601
+ 'huggingface' in str(os.environ.get('PATH', '')).lower() or
1602
+ os.path.exists('/.dockerenv')
1603
+ )
1604
+
1605
+ def create_downloadable_archive(run_dir, output_zip_path=None):
1606
+ if not is_running_on_huggingface():
1607
+ return None
1608
+
1609
+ if not os.path.exists(run_dir):
1610
+ print(f"Warning: Run directory does not exist: {run_dir}")
1611
+ return None
1612
+
1613
+ if output_zip_path is None:
1614
+ run_name = os.path.basename(run_dir)
1615
+ output_zip_path = os.path.join(os.path.dirname(os.path.dirname(run_dir)) if 'output' in run_dir else '.', f"{run_name}.zip")
1616
+ output_zip_path = os.path.abspath(output_zip_path)
1617
+
1618
+ print(f"\n[ARCHIVE] Creating downloadable archive: {output_zip_path}")
1619
+ print(f" (This file will be available in the Files tab)")
1620
+
1621
+ try:
1622
+ with zipfile.ZipFile(output_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
1623
+ for root, dirs, files in os.walk(run_dir):
1624
+ dirs[:] = [d for d in dirs if not d.startswith('.')]
1625
+
1626
+ for file in files:
1627
+ if file.startswith('.'):
1628
+ continue
1629
+
1630
+ file_path = os.path.join(root, file)
1631
+ arcname = os.path.relpath(file_path, os.path.dirname(run_dir))
1632
+ zipf.write(file_path, arcname)
1633
+
1634
+ zip_size = os.path.getsize(output_zip_path) / (1024 * 1024)
1635
+ print(f"[OK] Archive created successfully ({zip_size:.2f} MB)")
1636
+ print(f" File location: {os.path.abspath(output_zip_path)}")
1637
+ print(f" To download: Go to the 'Files' tab in your Hugging Face Space and download '{os.path.basename(output_zip_path)}'")
1638
+ return output_zip_path
1639
+ except Exception as e:
1640
+ print(f"[WARNING] Could not create archive: {e}")
1641
+ return None
1642
+
1643
+ def save_run_metadata(run_dir, args, successful_scenes, successful_renders):
1644
+ metadata = {
1645
+ 'timestamp': datetime.now().isoformat(),
1646
+ 'run_directory': run_dir,
1647
+ 'arguments': {
1648
+ 'num_scenes': args.num_scenes,
1649
+ 'num_objects': args.num_objects,
1650
+ 'min_objects': args.min_objects,
1651
+ 'max_objects': args.max_objects,
1652
+ 'num_counterfactuals': args.num_counterfactuals,
1653
+ 'use_gpu': args.use_gpu,
1654
+ 'samples': args.samples,
1655
+ 'width': args.width,
1656
+ 'height': args.height,
1657
+ 'cf_types': args.cf_types if args.cf_types else 'default (1 image + 1 negative)',
1658
+ 'semantic_only': getattr(args, 'semantic_only', False),
1659
+ 'negative_only': getattr(args, 'negative_only', False),
1660
+ 'min_cf_change_score': args.min_cf_change_score,
1661
+ 'max_cf_attempts': args.max_cf_attempts,
1662
+ },
1663
+
1664
+ 'results': {
1665
+ 'scenes_generated': successful_scenes,
1666
+ 'scenes_rendered': successful_renders,
1667
+ 'total_images': successful_renders * (args.num_counterfactuals + 1) if not args.skip_render else 0,
1668
+ },
1669
+ 'cf_types_used': args.cf_types if args.cf_types else list(COUNTERFACTUAL_TYPES.keys())
1670
+ }
1671
+
1672
+ metadata_path = os.path.join(run_dir, 'run_metadata.json')
1673
+ with open(metadata_path, 'w') as f:
1674
+ json.dump(metadata, f, indent=2)
1675
+
1676
+ print(f"\n[OK] Saved run metadata to: {metadata_path}")
1677
+
1678
+
1679
+ def regenerate_scene_sets(args):
1680
+ run_dir = os.path.join(args.output_dir, args.run_name)
1681
+ if not os.path.exists(run_dir):
1682
+ print(f"ERROR: Run directory does not exist: {run_dir}")
1683
+ return
1684
+
1685
+ metadata_path = os.path.join(run_dir, 'run_metadata.json')
1686
+ if not os.path.exists(metadata_path):
1687
+ print(f"ERROR: run_metadata.json not found in {run_dir}. Cannot determine original settings.")
1688
+ return
1689
+
1690
+ with open(metadata_path, 'r') as f:
1691
+ metadata = json.load(f)
1692
+ meta_args = metadata.get('arguments', {})
1693
+
1694
+ scenes_dir = os.path.join(run_dir, 'scenes')
1695
+ images_dir = os.path.join(run_dir, 'images')
1696
+ os.makedirs(scenes_dir, exist_ok=True)
1697
+ os.makedirs(images_dir, exist_ok=True)
1698
+
1699
+ blender_path = args.blender_path or find_blender()
1700
+ print(f"Using Blender: {blender_path}")
1701
+ print("\nPreparing scripts...")
1702
+ create_patched_render_script()
1703
+
1704
+ scene_indices = sorted(set(args.regenerate))
1705
+ num_counterfactuals = meta_args.get('num_counterfactuals', 2)
1706
+ cf_types = meta_args.get('cf_types')
1707
+ if isinstance(cf_types, list) and cf_types:
1708
+ pass
1709
+ else:
1710
+ cf_types = None
1711
+
1712
+ use_gpu = meta_args.get('use_gpu', 0)
1713
+ samples = meta_args.get('samples', 512)
1714
+ width = meta_args.get('width', 320)
1715
+ height = meta_args.get('height', 240)
1716
+
1717
+ print(f"\n{'='*70}")
1718
+ print(f"REGENERATING {len(scene_indices)} SCENE SETS: {scene_indices}")
1719
+ print(f"{'='*70}")
1720
+
1721
+ temp_run_id = os.path.basename(run_dir)
1722
+ checkpoint_file = os.path.join(run_dir, 'checkpoint.json')
1723
+ completed_scenes = load_checkpoint(checkpoint_file)
1724
+
1725
+ for i in scene_indices:
1726
+ print(f"\n{'='*70}")
1727
+ print(f"REGENERATING SCENE SET #{i}")
1728
+ print(f"{'='*70}")
1729
+
1730
+ num_objects = meta_args.get('num_objects')
1731
+ if num_objects is None:
1732
+ min_objs = meta_args.get('min_objects', 3)
1733
+ max_objs = meta_args.get('max_objects', 7)
1734
+ num_objects = random.randint(min_objs, max_objs)
1735
+
1736
+ base_scene = None
1737
+ for retry in range(3):
1738
+ base_scene = generate_base_scene(num_objects, blender_path, i, temp_run_dir=temp_run_id)
1739
+ if base_scene and len(base_scene.get('objects', [])) > 0:
1740
+ break
1741
+ print(f" Retry {retry + 1}/3...")
1742
+
1743
+ if not base_scene or len(base_scene.get('objects', [])) == 0:
1744
+ print(f" [FAILED] Could not generate base scene for #{i}")
1745
+ continue
1746
+
1747
+ min_cf_score = meta_args.get('min_cf_change_score', 1.0)
1748
+ max_cf_attempts = meta_args.get('max_cf_attempts', 10)
1749
+ min_noise = meta_args.get('min_noise_level', 'light')
1750
+
1751
+ counterfactuals = generate_counterfactuals(
1752
+ base_scene,
1753
+ num_counterfactuals,
1754
+ cf_types=cf_types,
1755
+ same_cf_type=meta_args.get('same_cf_type', False),
1756
+ min_change_score=min_cf_score,
1757
+ max_cf_attempts=max_cf_attempts,
1758
+ min_noise_level=min_noise,
1759
+ semantic_only=meta_args.get('semantic_only', False),
1760
+ negative_only=meta_args.get('negative_only', False)
1761
+ )
1762
+
1763
+ for idx, cf in enumerate(counterfactuals):
1764
+ print(f" CF{idx+1} [{cf.get('cf_category', '?')}] ({cf.get('type', '?')}): {cf.get('description', '')}")
1765
+
1766
+ scene_prefix = f"scene_{i:04d}"
1767
+ scene_paths = {'original': os.path.join(scenes_dir, f"{scene_prefix}_original.json")}
1768
+ image_paths = {'original': os.path.join(images_dir, f"{scene_prefix}_original.png")}
1769
+
1770
+ base_scene['cf_metadata'] = {
1771
+ 'variant': 'original', 'is_counterfactual': False, 'cf_index': None,
1772
+ 'cf_category': 'original', 'cf_type': None, 'cf_description': None, 'source_scene': scene_prefix,
1773
+ }
1774
+ save_scene(base_scene, scene_paths['original'])
1775
+
1776
+ for idx, cf in enumerate(counterfactuals):
1777
+ cf_name = f"cf{idx+1}"
1778
+ scene_paths[cf_name] = os.path.join(scenes_dir, f"{scene_prefix}_{cf_name}.json")
1779
+ image_paths[cf_name] = os.path.join(images_dir, f"{scene_prefix}_{cf_name}.png")
1780
+ cf_scene = cf['scene']
1781
+ cf_scene['cf_metadata'] = {
1782
+ 'variant': cf_name, 'is_counterfactual': True, 'cf_index': idx + 1,
1783
+ 'cf_category': cf.get('cf_category', 'unknown'), 'cf_type': cf.get('type', None),
1784
+ 'cf_description': cf.get('description', None), 'change_score': cf.get('change_score'),
1785
+ 'change_attempts': cf.get('change_attempts'), 'source_scene': scene_prefix,
1786
+ }
1787
+ save_scene(cf_scene, scene_paths[cf_name])
1788
+
1789
+ print(f" [OK] Saved {len(counterfactuals) + 1} scene files")
1790
+
1791
+ if not args.skip_render:
1792
+ print(" Rendering...")
1793
+ render_success = 0
1794
+ for scene_type, scene_path in scene_paths.items():
1795
+ if render_scene(blender_path, scene_path, image_paths[scene_type],
1796
+ use_gpu, samples, width, height):
1797
+ render_success += 1
1798
+ print(f" [OK] {scene_type}")
1799
+ print(f" [OK] Rendered {render_success}/{len(scene_paths)} images")
1800
+ completed_scenes.add(i)
1801
+
1802
+ save_checkpoint(checkpoint_file, list(completed_scenes))
1803
+
1804
+ temp_run_path = os.path.join(os.getcwd(), 'temp_output', temp_run_id)
1805
+ if os.path.exists(temp_run_path):
1806
+ shutil.rmtree(temp_run_path)
1807
+ if os.path.exists('render_images_patched.py'):
1808
+ try:
1809
+ os.remove('render_images_patched.py')
1810
+ except Exception:
1811
+ pass
1812
+
1813
+ print(f"\n{'='*70}")
1814
+ print("REGENERATION COMPLETE")
1815
+ print(f"{'='*70}")
1816
+ print(f"Regenerated {len(scene_indices)} scene sets: {scene_indices}")
1817
+ print(f"Run directory: {run_dir}")
1818
+
1819
+ if args.generate_questions:
1820
+ if generate_mapping_with_questions is None:
1821
+ print("\n[WARNING] Questions module not found. Skipping CSV generation.")
1822
+ else:
1823
+ print("\nRegenerating questions CSV...")
1824
+ try:
1825
+ generate_mapping_with_questions(
1826
+ run_dir, args.csv_name, generate_questions=True,
1827
+ strict_question_validation=not getattr(args, 'no_strict_validation', False)
1828
+ )
1829
+ print(f"[OK] CSV saved to: {os.path.join(run_dir, args.csv_name)}")
1830
+ except Exception as e:
1831
+ print(f"[ERROR] Questions: {e}")
1832
+ import traceback
1833
+ traceback.print_exc()
1834
+
1835
+
1836
+ def filter_same_answer_scenes(run_dir, csv_filename):
1837
+ """Remove CSV rows where CF1 or CF2 answer matches original; delete those scenes' images and scene JSONs."""
1838
+ csv_path = os.path.join(run_dir, csv_filename)
1839
+ if not os.path.isfile(csv_path):
1840
+ return
1841
+ with open(csv_path, 'r', encoding='utf-8') as f:
1842
+ reader = csv.reader(f)
1843
+ header = next(reader)
1844
+ try:
1845
+ idx_orig_ans = header.index('original_image_answer_to_original_question')
1846
+ idx_cf1_ans = header.index('cf1_image_answer_to_cf1_question')
1847
+ idx_cf2_ans = header.index('cf2_image_answer_to_cf2_question')
1848
+ idx_orig_img = header.index('original_image')
1849
+ except ValueError:
1850
+ return
1851
+ kept_rows = [header]
1852
+ removed_scene_ids = set()
1853
+ with open(csv_path, 'r', encoding='utf-8') as f:
1854
+ reader = csv.reader(f)
1855
+ next(reader)
1856
+ for row in reader:
1857
+ if len(row) <= max(idx_orig_ans, idx_cf1_ans, idx_cf2_ans, idx_orig_img):
1858
+ kept_rows.append(row)
1859
+ continue
1860
+ o = str(row[idx_orig_ans]).strip().lower()
1861
+ c1 = str(row[idx_cf1_ans]).strip().lower()
1862
+ c2 = str(row[idx_cf2_ans]).strip().lower()
1863
+ if o == c1 or o == c2:
1864
+ orig_img = row[idx_orig_img]
1865
+ if orig_img.endswith('_original.png'):
1866
+ scene_id = orig_img.replace('_original.png', '')
1867
+ removed_scene_ids.add(scene_id)
1868
+ continue
1869
+ kept_rows.append(row)
1870
+ if not removed_scene_ids:
1871
+ return
1872
+ with open(csv_path, 'w', newline='', encoding='utf-8') as f:
1873
+ writer = csv.writer(f, quoting=csv.QUOTE_ALL)
1874
+ writer.writerows(kept_rows)
1875
+ images_dir = os.path.join(run_dir, 'images')
1876
+ scenes_dir = os.path.join(run_dir, 'scenes')
1877
+ deleted = 0
1878
+ for scene_id in removed_scene_ids:
1879
+ for suffix in ('_original', '_cf1', '_cf2'):
1880
+ for d, ext in [(images_dir, '.png'), (scenes_dir, '.json')]:
1881
+ if not os.path.isdir(d):
1882
+ continue
1883
+ fn = scene_id + suffix + ext
1884
+ fp = os.path.join(d, fn)
1885
+ if os.path.isfile(fp):
1886
+ try:
1887
+ os.remove(fp)
1888
+ deleted += 1
1889
+ except OSError:
1890
+ pass
1891
+ print(f"\n[OK] Filtered {len(removed_scene_ids)} scenes where answers matched; removed {deleted} files. CSV now has {len(kept_rows) - 1} rows.")
1892
+
1893
+
1894
+ def main():
1895
+ script_dir = os.path.dirname(os.path.abspath(__file__))
1896
+ os.chdir(script_dir)
1897
+
1898
+ parser = argparse.ArgumentParser(description='Generate and render multiple CLEVR scene sets with diverse counterfactuals and resume support')
1899
+ parser.add_argument('--num_scenes', type=int, default=5,
1900
+ help='Number of scene sets to generate')
1901
+ parser.add_argument('--num_objects', type=int, default=None,
1902
+ help='Fixed number of objects per scene')
1903
+ parser.add_argument('--min_objects', type=int, default=3,
1904
+ help='Minimum objects per scene (if num_objects not set)')
1905
+ parser.add_argument('--max_objects', type=int, default=7,
1906
+ help='Maximum objects per scene (if num_objects not set)')
1907
+ parser.add_argument('--num_counterfactuals', type=int, default=2,
1908
+ help='Number of counterfactual variants per scene (default: 2 = 1 Image CF + 1 Negative CF)')
1909
+ parser.add_argument('--blender_path', default=None,
1910
+ help='Path to Blender executable')
1911
+ parser.add_argument('--output_dir', default='output',
1912
+ help='Base output directory')
1913
+ parser.add_argument('--run_name', default=None,
1914
+ help='Optional name for this run (required for resume)')
1915
+ parser.add_argument('--resume', action='store_true',
1916
+ help='Resume from last checkpoint (requires --run_name)')
1917
+ parser.add_argument('--use_gpu', type=int, default=0,
1918
+ help='Use GPU rendering (0 or 1)')
1919
+ parser.add_argument('--samples', type=int, default=512,
1920
+ help='Number of render samples')
1921
+ parser.add_argument('--width', type=int, default=320,
1922
+ help='Image width in pixels (default: 320)')
1923
+ parser.add_argument('--height', type=int, default=240,
1924
+ help='Image height in pixels (default: 240)')
1925
+ parser.add_argument('--skip_render', action='store_true',
1926
+ help='Only generate scenes, skip rendering')
1927
+ parser.add_argument('--render_only', action='store_true',
1928
+ help='Only render existing scene JSON files. Requires --run_dir, --run_name, or --auto_latest')
1929
+ parser.add_argument('--run_dir', type=str, default=None,
1930
+ help='Run directory containing scenes/ folder (for --render_only mode)')
1931
+ parser.add_argument('--auto_latest', action='store_true',
1932
+ help='Automatically use the latest run in output_dir (for --render_only mode)')
1933
+ parser.add_argument('--cf_types', nargs='+',
1934
+ choices=[
1935
+ 'change_color', 'change_shape', 'change_size',
1936
+ 'change_material', 'change_position',
1937
+ 'add_object', 'remove_object', 'replace_object',
1938
+ 'swap_attribute', 'occlusion_change', 'relational_flip',
1939
+ 'change_background',
1940
+ 'change_lighting', 'add_noise',
1941
+ 'apply_fisheye', 'apply_blur', 'apply_vignette', 'apply_chromatic_aberration'
1942
+ ],
1943
+ help='Specific counterfactual types to use (if not specified, uses default: 1 image CF + 1 negative CF)')
1944
+ parser.add_argument('--semantic_only', action='store_true',
1945
+ help='Generate only Semantic/Image counterfactuals (e.g. Change Color, Add Object); no Negative CFs')
1946
+ parser.add_argument('--negative_only', action='store_true',
1947
+ help='Generate only Negative counterfactuals (e.g. Change Lighting, Add Noise, Occlusion Change); no Semantic CFs')
1948
+ parser.add_argument('--same_cf_type', action='store_true',
1949
+ help='Use the same counterfactual type for all variants (first in --cf_types, or one random type if --cf_types not set)')
1950
+ parser.add_argument('--min_cf_change_score', type=float, default=1.0,
1951
+ help='Minimum heuristic change score for a counterfactual to be accepted (retries until met). Increase for more noticeable CFs.')
1952
+ parser.add_argument('--max_cf_attempts', type=int, default=10,
1953
+ help='Max retries per counterfactual type to meet --min_cf_change_score (default: 10)')
1954
+ parser.add_argument('--min_noise_level', type=str, default='light',
1955
+ choices=['light', 'medium', 'heavy'],
1956
+ help='Minimum noise level when using add_noise counterfactual (default: light)')
1957
+
1958
+ parser.add_argument('--list_cf_types', action='store_true',
1959
+ help='List all available counterfactual types and exit')
1960
+
1961
+ parser.add_argument('--generate_questions', action='store_true',
1962
+ help='Create questions and answers CSV after rendering completes')
1963
+ parser.add_argument('--filter_same_answer', action='store_true',
1964
+ help='After generating questions, remove scenes where CF1 or CF2 answer matches original (delete those rows and their image/scene files). Use with --generate_questions.')
1965
+ parser.add_argument('--csv_name', default='image_mapping_with_questions.csv',
1966
+ help='Output CSV filename (default: image_mapping_with_questions.csv)')
1967
+ parser.add_argument('--no_strict_validation', action='store_true',
1968
+ help='Disable strict question validation (Semantic-Valid / Negative-Valid); use legacy accept logic when generating questions CSV')
1969
+ parser.add_argument('--regenerate', nargs='+', type=int, metavar='N',
1970
+ help='Regenerate specific scene sets by index (e.g. --regenerate 63 83 272). Requires --run_name. Uses settings from run_metadata.json.')
1971
+
1972
+ args = parser.parse_args()
1973
+
1974
+ if args.list_cf_types:
1975
+ list_counterfactual_types()
1976
+ return
1977
+
1978
+ if args.render_only:
1979
+ render_existing_scenes(args)
1980
+ return
1981
+
1982
+ if args.resume and not args.run_name:
1983
+ print("ERROR: --run_name is required when using --resume")
1984
+ return
1985
+
1986
+ if args.regenerate is not None:
1987
+ if not args.run_name:
1988
+ print("ERROR: --run_name is required when using --regenerate")
1989
+ return
1990
+ regenerate_scene_sets(args)
1991
+ return
1992
+
1993
+ blender_path = args.blender_path or find_blender()
1994
+ print(f"Using Blender: {blender_path}")
1995
+
1996
+ print("\nPreparing scripts...")
1997
+ create_patched_render_script()
1998
+ run_dir = create_run_directory(args.output_dir, args.run_name)
1999
+ temp_run_id = os.path.basename(run_dir)
2000
+ print(f"\n{'='*70}")
2001
+ print(f"RUN DIRECTORY: {run_dir}")
2002
+ print(f"{'='*70}")
2003
+
2004
+ scenes_dir = os.path.join(run_dir, 'scenes')
2005
+ images_dir = os.path.join(run_dir, 'images')
2006
+ os.makedirs(scenes_dir, exist_ok=True)
2007
+ os.makedirs(images_dir, exist_ok=True)
2008
+
2009
+ checkpoint_file = os.path.join(run_dir, 'checkpoint.json')
2010
+
2011
+ completed_scenes = set()
2012
+ if args.resume:
2013
+ completed_scenes = load_checkpoint(checkpoint_file)
2014
+ rendered_scenes = get_completed_scenes_from_folder(images_dir)
2015
+ completed_scenes.update(rendered_scenes)
2016
+
2017
+ if completed_scenes:
2018
+ print(f"\n[RESUME] Found {len(completed_scenes)} already completed scenes")
2019
+ print(f" Completed: {sorted(completed_scenes)}")
2020
+ print(f" Will generate scenes: {args.num_scenes}")
2021
+ print(f" Remaining: {args.num_scenes - len(completed_scenes)}")
2022
+ else:
2023
+ print("\n[WARNING] Resume flag set but no checkpoint found, starting fresh")
2024
+
2025
+ print("\n" + "="*70)
2026
+ print(f"GENERATING {args.num_scenes} SCENE SETS")
2027
+ print(f"Each with {args.num_counterfactuals} counterfactual variants")
2028
+ print(f"Available CF types: {len(COUNTERFACTUAL_TYPES)}")
2029
+ print("="*70)
2030
+
2031
+ successful_scenes = 0
2032
+ successful_renders = 0
2033
+
2034
+ for i in range(args.num_scenes):
2035
+ if i in completed_scenes:
2036
+ print(f"\n[SKIP] Skipping scene {i} (already completed)")
2037
+ successful_scenes += 1
2038
+ successful_renders += 1
2039
+ continue
2040
+
2041
+ print(f"\n{'='*70}")
2042
+ print(f"SCENE SET {i+1}/{args.num_scenes} (Scene #{i})")
2043
+ print(f"{'='*70}")
2044
+
2045
+ if args.num_objects is not None:
2046
+ num_objects = args.num_objects
2047
+ else:
2048
+ num_objects = random.randint(args.min_objects, args.max_objects)
2049
+
2050
+ base_scene = None
2051
+ for retry in range(3):
2052
+ base_scene = generate_base_scene(num_objects, blender_path, i, temp_run_dir=temp_run_id)
2053
+ if base_scene and len(base_scene['objects']) > 0:
2054
+ break
2055
+ print(f" Retry {retry + 1}/3...")
2056
+
2057
+ if not base_scene or len(base_scene['objects']) == 0:
2058
+ print(f" [FAILED] Failed to generate scene {i+1}")
2059
+ continue
2060
+
2061
+ successful_scenes += 1
2062
+
2063
+ print(f" Creating {args.num_counterfactuals} counterfactuals...")
2064
+ counterfactuals = generate_counterfactuals(
2065
+ base_scene,
2066
+ args.num_counterfactuals,
2067
+ cf_types=args.cf_types,
2068
+ same_cf_type=args.same_cf_type,
2069
+ min_change_score=args.min_cf_change_score,
2070
+ max_cf_attempts=args.max_cf_attempts,
2071
+ min_noise_level=args.min_noise_level,
2072
+ semantic_only=args.semantic_only,
2073
+ negative_only=args.negative_only
2074
+ )
2075
+
2076
+ for idx, cf in enumerate(counterfactuals):
2077
+ cf_cat = cf.get('cf_category', 'unknown')
2078
+ print(f" CF{idx+1} [{cf_cat}] ({cf['type']}): {cf['description']}")
2079
+
2080
+ scene_prefix = f"scene_{i:04d}"
2081
+ scene_paths = {'original': os.path.join(scenes_dir, f"{scene_prefix}_original.json")}
2082
+ image_paths = {'original': os.path.join(images_dir, f"{scene_prefix}_original.png")}
2083
+
2084
+ base_scene['cf_metadata'] = {
2085
+ 'variant': 'original',
2086
+ 'is_counterfactual': False,
2087
+ 'cf_index': None,
2088
+ 'cf_category': 'original',
2089
+ 'cf_type': None,
2090
+ 'cf_description': None,
2091
+ 'source_scene': scene_prefix,
2092
+ }
2093
+ save_scene(base_scene, scene_paths['original'])
2094
+
2095
+ for idx, cf in enumerate(counterfactuals):
2096
+ cf_name = f"cf{idx+1}"
2097
+ scene_paths[cf_name] = os.path.join(scenes_dir, f"{scene_prefix}_{cf_name}.json")
2098
+ image_paths[cf_name] = os.path.join(images_dir, f"{scene_prefix}_{cf_name}.png")
2099
+
2100
+ cf_scene = cf['scene']
2101
+ cf_scene['cf_metadata'] = {
2102
+ 'variant': cf_name,
2103
+ 'is_counterfactual': True,
2104
+ 'cf_index': idx + 1,
2105
+ 'cf_category': cf.get('cf_category', 'unknown'),
2106
+ 'cf_type': cf.get('type', None),
2107
+ 'cf_description': cf.get('description', None),
2108
+ 'change_score': cf.get('change_score', None),
2109
+ 'change_attempts': cf.get('change_attempts', None),
2110
+ 'source_scene': scene_prefix,
2111
+ }
2112
+ save_scene(cf_scene, scene_paths[cf_name])
2113
+
2114
+ print(f" [OK] Saved {len(counterfactuals) + 1} scene files")
2115
+
2116
+ if not args.skip_render:
2117
+ print(" Rendering...")
2118
+ render_success = 0
2119
+ total_to_render = len(counterfactuals) + 1
2120
+
2121
+ for scene_type, scene_path in scene_paths.items():
2122
+ if render_scene(blender_path, scene_path, image_paths[scene_type],
2123
+ args.use_gpu, args.samples, args.width, args.height):
2124
+ render_success += 1
2125
+ print(f" [OK] {scene_type}")
2126
+
2127
+ if render_success == total_to_render:
2128
+ successful_renders += 1
2129
+ completed_scenes.add(i)
2130
+ save_checkpoint(checkpoint_file, list(completed_scenes))
2131
+
2132
+ print(f" [OK] Rendered {render_success}/{total_to_render} images")
2133
+ else:
2134
+ completed_scenes.add(i)
2135
+ save_checkpoint(checkpoint_file, list(completed_scenes))
2136
+
2137
+ save_run_metadata(run_dir, args, successful_scenes, successful_renders)
2138
+
2139
+ archive_path = create_downloadable_archive(run_dir)
2140
+
2141
+ temp_run_path = os.path.join(os.getcwd(), 'temp_output', temp_run_id)
2142
+ if os.path.exists(temp_run_path):
2143
+ shutil.rmtree(temp_run_path)
2144
+ if os.path.exists('render_images_patched.py'):
2145
+ os.remove('render_images_patched.py')
2146
+
2147
+ print("\n" + "="*70)
2148
+ print("PIPELINE COMPLETE")
2149
+ print("="*70)
2150
+ print(f"Run directory: {run_dir}")
2151
+ print(f"Successfully generated: {successful_scenes}/{args.num_scenes} scene sets")
2152
+ if not args.skip_render:
2153
+ print(f"Successfully rendered: {successful_renders}/{successful_scenes} scene sets")
2154
+ print(f"\nOutput:")
2155
+ print(f" Scene files: {scenes_dir}/")
2156
+ if not args.skip_render:
2157
+ print(f" Images: {images_dir}/")
2158
+ print(f" Metadata: {os.path.join(run_dir, 'run_metadata.json')}")
2159
+ print(f" Checkpoint: {checkpoint_file}")
2160
+ if archive_path:
2161
+ print(f"\n[ARCHIVE] Downloadable archive created:")
2162
+ print(f" Filename: {os.path.basename(archive_path)}")
2163
+ print(f" Location: {os.path.abspath(archive_path)}")
2164
+ print(f" To download: Go to the 'Files' tab in your Hugging Face Space")
2165
+ print(f" and look for '{os.path.basename(archive_path)}' in the file list")
2166
+ print(f"\nFile naming: scene_XXXX_original/cf1/cf2/....json/png")
2167
+ if args.cf_types:
2168
+ print(f"\nCounterfactual types requested: {', '.join(args.cf_types)}")
2169
+ else:
2170
+ print("\nCounterfactual types: default (mix of image_cf + negative_cf)")
2171
+
2172
+ if args.resume and completed_scenes:
2173
+ print(f"\n[OK] Resume successful! Completed {len(completed_scenes)}/{args.num_scenes} scenes total")
2174
+
2175
+ if args.generate_questions and not args.skip_render:
2176
+ if generate_mapping_with_questions is None:
2177
+ print("\n[WARNING] Questions module not found. Skipping.")
2178
+ else:
2179
+ print("\n" + "="*70)
2180
+ print("QUESTIONS AND ANSWERS")
2181
+ print("="*70)
2182
+ try:
2183
+ generate_mapping_with_questions(
2184
+ run_dir,
2185
+ args.csv_name if args.num_counterfactuals != 1 else 'image_mapping_single_cf.csv',
2186
+ generate_questions=True,
2187
+ strict_question_validation=not getattr(args, 'no_strict_validation', False),
2188
+ single_cf_per_row=(args.num_counterfactuals == 1)
2189
+ )
2190
+ csv_used = args.csv_name if args.num_counterfactuals != 1 else 'image_mapping_single_cf.csv'
2191
+ print(f"\n[OK] CSV saved to: {os.path.join(run_dir, csv_used)}")
2192
+ if getattr(args, 'filter_same_answer', False):
2193
+ filter_same_answer_scenes(run_dir, csv_used)
2194
+ except Exception as e:
2195
+ print(f"\n[ERROR] Questions: {e}")
2196
+ import traceback
2197
+ traceback.print_exc()
2198
+
2199
+ if __name__ == '__main__':
2200
+ main()
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core dependencies
2
+ numpy>=1.24.0
3
+ Pillow>=10.0.0
4
+
5
+ # Streamlit for UI
6
+ streamlit>=1.28.0
7
+
8
+ # PyTorch and Hugging Face dependencies
9
+ # Using flexible versions for compatibility with Python 3.13
10
+ torch>=2.0.0
11
+ transformers>=4.35.0
12
+ huggingface_hub>=0.16.4,<0.18
scripts/README.md ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Scripts
2
+
3
+ Utility scripts for working with CLEVR counterfactual scenes.
4
+
5
+ ## Available Scripts
6
+
7
+ ### generate_scenes.py
8
+ Generate scene JSON files with counterfactuals (no rendering).
9
+
10
+ **Purpose**: Create scene JSON files that can be rendered later.
11
+
12
+ **Usage:**
13
+ ```bash
14
+ # Generate 10 scene sets
15
+ python scripts/generate_scenes.py --num_scenes 10 --num_objects 5 --run_name experiment1
16
+
17
+ # Resume from checkpoint
18
+ python scripts/generate_scenes.py --num_scenes 100 --run_name experiment1 --resume
19
+ ```
20
+
21
+ **Output**: Scene JSON files in `output/run_name/scenes/`
22
+
23
+ **Next step**: Use `pipeline.py --render_only` to render these scenes.
24
+
25
+ **Note**: Alternatively, you can use `pipeline.py --skip_render` instead of this script.
26
+
27
+ ---
28
+
29
+ ### generate_examples.py
30
+ Generate examples of each counterfactual type applied to a base scene. Optionally renders scenes to images.
31
+
32
+ **Purpose**: Create reference examples demonstrating all counterfactual types.
33
+
34
+ **Usage:**
35
+ ```bash
36
+ # Generate scene JSON files only
37
+ python scripts/generate_examples.py [--output_dir DIR] [--num_objects N]
38
+
39
+ # Generate and render to images
40
+ python scripts/generate_examples.py --render [--output_dir DIR] [--num_objects N] [--use_gpu 0|1]
41
+ ```
42
+
43
+ **Options:**
44
+ - `--output_dir`: Output directory (default: `output/counterfactual_examples`)
45
+ - `--num_objects`: Number of objects in base scene (default: 5)
46
+ - `--render`: Render scenes to PNG images
47
+ - `--use_gpu`: Use GPU rendering (0 = CPU, 1 = GPU, default: 0)
48
+
49
+ **Output**:
50
+ - Scene JSON files for all counterfactual types
51
+ - Optional: PNG images (if `--render` is used)
52
+
53
+ ---
54
+
55
+ ### generate_questions_mapping.py
56
+ Generate CSV mapping with questions and counterfactual questions for scenes.
57
+
58
+ **Purpose**: Create question-answer datasets for training/evaluation.
59
+
60
+ **Usage:**
61
+ ```bash
62
+ # For a specific run directory
63
+ python scripts/generate_questions_mapping.py --output_dir output/experiment1 --generate_questions
64
+
65
+ # Auto-detect latest run
66
+ python scripts/generate_questions_mapping.py --output_dir output --auto_latest --generate_questions
67
+
68
+ # Generate CSV with scene_id and links (relative paths)
69
+ python scripts/generate_questions_mapping.py --output_dir output/experiment1 --generate_questions --with_links
70
+
71
+ # Generate CSV with scene_id and full URLs
72
+ python scripts/generate_questions_mapping.py --output_dir output/experiment1 --generate_questions --with_links --base_url https://example.com/dataset
73
+ ```
74
+
75
+ **Options:**
76
+ - `--output_dir`: Run directory or base output directory (default: `output`)
77
+ - `--auto_latest`: Automatically find and use the latest run in output_dir
78
+ - `--csv_name`: Output CSV filename (default: `image_mapping_with_questions.csv`)
79
+ - `--generate_questions`: Generate questions and answers for each scene set
80
+ - `--with_links`: Include scene_id and image/scene link columns (for URLs or file paths)
81
+ - `--base_url`: Base URL for links (e.g., `https://example.com`). If not provided, uses relative paths like `images/filename.png`
82
+
83
+ **Output**: CSV files with question-answer mappings
84
+
85
+ ---
86
+
87
+
88
+ ## Main Pipeline
89
+
90
+ For production use (generating large datasets), use the main pipeline script:
91
+
92
+ ```bash
93
+ python pipeline.py --num_scenes 100 --num_objects 5 --run_name my_experiment
94
+ ```
95
+
96
+ See the main `README.md` for full documentation of the production pipeline.
97
+
98
+ ---
99
+
100
+ ## Script Summary
101
+
102
+ | Script | Purpose | When to Use |
103
+ |--------|---------|-------------|
104
+ | `generate_scenes.py` | Generate scene JSON files | Generate scenes separately (alternative to `pipeline.py --skip_render`) |
105
+ | `generate_examples.py` | Generate reference examples | Creating demonstrations, testing counterfactuals |
106
+ | `generate_questions_mapping.py` | Create QA datasets | Preparing training/evaluation data |
107
+ | `pipeline.py` | Combined generation + rendering | Main entry point. Supports `--skip_render` and `--render_only` modes |
scripts/__pycache__/generate_examples.cpython-312.pyc ADDED
Binary file (9.84 kB). View file
 
scripts/__pycache__/generate_questions_mapping.cpython-312.pyc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:61369dbc63eb9bd066d8a9fe9d5f0ac6f1f57645ea4705ec163aadaf1665ef66
3
+ size 103022
scripts/__pycache__/generate_scenes.cpython-312.pyc ADDED
Binary file (10.9 kB). View file
 
scripts/__pycache__/render.cpython-312.pyc ADDED
Binary file (53.9 kB). View file
 
scripts/generate_examples.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import copy
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+ script_dir = os.path.dirname(os.path.abspath(__file__))
11
+ project_root = os.path.dirname(script_dir)
12
+ sys.path.insert(0, project_root)
13
+
14
+ from pipeline import (
15
+ generate_base_scene,
16
+ find_blender,
17
+ create_patched_utils,
18
+ create_patched_render_script,
19
+ create_render_from_json_script,
20
+ save_scene,
21
+ render_scene,
22
+ IMAGE_COUNTERFACTUALS,
23
+ NEGATIVE_COUNTERFACTUALS,
24
+ COUNTERFACTUAL_TYPES
25
+ )
26
+
27
+ def generate_all_counterfactual_examples(output_dir='output/counterfactual_examples',
28
+ num_objects=5,
29
+ render=False,
30
+ use_gpu=0):
31
+ print("="*70)
32
+ print("GENERATING COUNTERFACTUAL EXAMPLES")
33
+ print("="*70)
34
+
35
+ script_dir = os.path.dirname(os.path.abspath(__file__))
36
+ project_root = os.path.dirname(script_dir)
37
+ if not os.path.isabs(output_dir):
38
+ output_dir = os.path.join(project_root, output_dir)
39
+
40
+ os.makedirs(output_dir, exist_ok=True)
41
+ scenes_dir = os.path.join(output_dir, 'scenes')
42
+ images_dir = os.path.join(output_dir, 'images') if render else None
43
+ if render:
44
+ os.makedirs(images_dir, exist_ok=True)
45
+ os.makedirs(scenes_dir, exist_ok=True)
46
+
47
+ blender_path = find_blender()
48
+ create_patched_utils()
49
+ create_patched_render_script()
50
+ if render:
51
+ create_render_from_json_script()
52
+
53
+ print(f"\n1. Generating base scene with {num_objects} objects...")
54
+ base_scene = None
55
+ for retry in range(3):
56
+ base_scene = generate_base_scene(num_objects, blender_path, 0)
57
+ if base_scene and len(base_scene.get('objects', [])) > 0:
58
+ break
59
+ print(f" Retry {retry + 1}/3...")
60
+
61
+ if not base_scene or len(base_scene.get('objects', [])) == 0:
62
+ print("ERROR: Failed to generate base scene")
63
+ return
64
+
65
+ original_path = os.path.join(scenes_dir, '00_original.json')
66
+ save_scene(base_scene, original_path)
67
+ print(f" [OK] Saved original scene: {original_path}")
68
+
69
+ if render:
70
+ original_image = os.path.join(images_dir, '00_original.png')
71
+ print(f" Rendering original scene...")
72
+ render_scene(blender_path, original_path, original_image, use_gpu=use_gpu)
73
+ if os.path.exists(original_image):
74
+ print(f" [OK] Rendered: 00_original.png")
75
+
76
+ all_cf_types = {**IMAGE_COUNTERFACTUALS, **NEGATIVE_COUNTERFACTUALS}
77
+ total_cfs = len(all_cf_types)
78
+
79
+ print(f"\n2. Generating {total_cfs} counterfactual examples...")
80
+
81
+ image_cf_index = 1
82
+ for cf_type, cf_func in sorted(IMAGE_COUNTERFACTUALS.items()):
83
+ print(f"\n [Image CF {image_cf_index}/{len(IMAGE_COUNTERFACTUALS)}] Applying: {cf_type}")
84
+
85
+ try:
86
+ cf_scene, description = cf_func(copy.deepcopy(base_scene))
87
+
88
+ cf_scene['cf_metadata'] = {
89
+ 'variant': f'image_cf_{image_cf_index}',
90
+ 'is_counterfactual': True,
91
+ 'cf_index': image_cf_index,
92
+ 'cf_category': 'image_cf',
93
+ 'cf_type': cf_type,
94
+ 'cf_description': description,
95
+ 'source_scene': '00_original'
96
+ }
97
+
98
+ filename = f"01_image_{image_cf_index:02d}_{cf_type}.json"
99
+ cf_path = os.path.join(scenes_dir, filename)
100
+
101
+ save_scene(cf_scene, cf_path)
102
+ print(f" [OK] Saved: {filename}")
103
+ print(f" Description: {description}")
104
+
105
+ if render:
106
+ cf_image = os.path.join(images_dir, filename.replace('.json', '.png'))
107
+ render_scene(blender_path, cf_path, cf_image, use_gpu=use_gpu)
108
+ if os.path.exists(cf_image):
109
+ print(f" [OK] Rendered: {filename.replace('.json', '.png')}")
110
+
111
+ image_cf_index += 1
112
+
113
+ except Exception as e:
114
+ print(f" [ERROR] ERROR applying {cf_type}: {e}")
115
+ import traceback
116
+ traceback.print_exc()
117
+ continue
118
+
119
+ negative_cf_index = 1
120
+ for cf_type, cf_func in sorted(NEGATIVE_COUNTERFACTUALS.items()):
121
+ print(f"\n [Negative CF {negative_cf_index}/{len(NEGATIVE_COUNTERFACTUALS)}] Applying: {cf_type}")
122
+
123
+ try:
124
+ if cf_type == 'add_noise':
125
+ cf_scene, description = cf_func(copy.deepcopy(base_scene), min_noise_level='medium')
126
+ else:
127
+ cf_scene, description = cf_func(copy.deepcopy(base_scene))
128
+
129
+ cf_scene['cf_metadata'] = {
130
+ 'variant': f'negative_cf_{negative_cf_index}',
131
+ 'is_counterfactual': True,
132
+ 'cf_index': negative_cf_index,
133
+ 'cf_category': 'negative_cf',
134
+ 'cf_type': cf_type,
135
+ 'cf_description': description,
136
+ 'source_scene': '00_original'
137
+ }
138
+
139
+ filename = f"02_negative_{negative_cf_index:02d}_{cf_type}.json"
140
+ cf_path = os.path.join(scenes_dir, filename)
141
+
142
+ save_scene(cf_scene, cf_path)
143
+ print(f" [OK] Saved: {filename}")
144
+ print(f" Description: {description}")
145
+
146
+ if render:
147
+ cf_image = os.path.join(images_dir, filename.replace('.json', '.png'))
148
+ render_scene(blender_path, cf_path, cf_image, use_gpu=use_gpu)
149
+ if os.path.exists(cf_image):
150
+ print(f" [OK] Rendered: {filename.replace('.json', '.png')}")
151
+
152
+ negative_cf_index += 1
153
+
154
+ except Exception as e:
155
+ print(f" [ERROR] ERROR applying {cf_type}: {e}")
156
+ import traceback
157
+ traceback.print_exc()
158
+ continue
159
+
160
+ summary = {
161
+ 'base_scene': '00_original.json',
162
+ 'total_counterfactuals': len(all_cf_types),
163
+ 'image_counterfactuals': len(IMAGE_COUNTERFACTUALS),
164
+ 'negative_counterfactuals': len(NEGATIVE_COUNTERFACTUALS),
165
+ 'counterfactuals': {}
166
+ }
167
+
168
+ image_cf_index = 1
169
+ for cf_type in sorted(IMAGE_COUNTERFACTUALS.keys()):
170
+ filename = f"01_image_{image_cf_index:02d}_{cf_type}.json"
171
+ summary['counterfactuals'][cf_type] = {
172
+ 'filename': filename,
173
+ 'category': 'image_cf',
174
+ 'index': image_cf_index
175
+ }
176
+ image_cf_index += 1
177
+
178
+ negative_cf_index = 1
179
+ for cf_type in sorted(NEGATIVE_COUNTERFACTUALS.keys()):
180
+ filename = f"02_negative_{negative_cf_index:02d}_{cf_type}.json"
181
+ summary['counterfactuals'][cf_type] = {
182
+ 'filename': filename,
183
+ 'category': 'negative_cf',
184
+ 'index': negative_cf_index
185
+ }
186
+ negative_cf_index += 1
187
+
188
+ summary_path = os.path.join(output_dir, 'summary.json')
189
+ with open(summary_path, 'w') as f:
190
+ json.dump(summary, f, indent=2)
191
+
192
+ print("\n" + "="*70)
193
+ print("SUMMARY")
194
+ print("="*70)
195
+ print(f"Base scene: {original_path}")
196
+ print(f"Total counterfactuals generated: {len(all_cf_types)}")
197
+ print(f" - Image CFs: {len(IMAGE_COUNTERFACTUALS)}")
198
+ print(f" - Negative CFs: {len(NEGATIVE_COUNTERFACTUALS)}")
199
+ print(f"\nAll files saved to: {output_dir}")
200
+ print(f"Summary saved to: {summary_path}")
201
+ if render:
202
+ print(f"Images saved to: {images_dir}")
203
+ print("="*70)
204
+
205
+ if __name__ == '__main__':
206
+ parser = argparse.ArgumentParser(description='Generate examples of each counterfactual type')
207
+ parser.add_argument('--output_dir', type=str, default='output/counterfactual_examples',
208
+ help='Output directory for examples (default: output/counterfactual_examples)')
209
+ parser.add_argument('--num_objects', type=int, default=5,
210
+ help='Number of objects in base scene (default: 5)')
211
+ parser.add_argument('--render', action='store_true',
212
+ help='Render scenes to images')
213
+ parser.add_argument('--use_gpu', type=int, default=0,
214
+ help='Use GPU rendering (0 = CPU, 1 = GPU, default: 0)')
215
+
216
+ args = parser.parse_args()
217
+
218
+ generate_all_counterfactual_examples(args.output_dir, args.num_objects, args.render, args.use_gpu)
scripts/generate_questions_mapping.py ADDED
The diff for this file is too large to render. See raw diff
 
scripts/generate_scenes.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+
3
+ import sys
4
+ import os
5
+
6
+ script_dir = os.path.dirname(os.path.abspath(__file__))
7
+ project_root = os.path.dirname(script_dir)
8
+ sys.path.insert(0, project_root)
9
+
10
+ from pipeline import (
11
+ find_blender,
12
+ create_patched_render_script,
13
+ create_run_directory,
14
+ generate_base_scene,
15
+ generate_counterfactuals,
16
+ save_scene,
17
+ save_checkpoint,
18
+ load_checkpoint,
19
+ get_completed_scenes_from_folder,
20
+ COUNTERFACTUAL_TYPES,
21
+ list_counterfactual_types
22
+ )
23
+ import argparse
24
+ import random
25
+ import json
26
+ import shutil
27
+ from datetime import datetime
28
+
29
+ def main():
30
+ parser = argparse.ArgumentParser(
31
+ description='Generate scene JSON files with counterfactuals (no rendering)'
32
+ )
33
+
34
+ parser.add_argument('--num_scenes', type=int, default=5,
35
+ help='Number of scene sets to generate')
36
+ parser.add_argument('--num_objects', type=int, default=None,
37
+ help='Fixed number of objects per scene (overrides min/max)')
38
+ parser.add_argument('--min_objects', type=int, default=3,
39
+ help='Minimum object count (if num_objects not given)')
40
+ parser.add_argument('--max_objects', type=int, default=7,
41
+ help='Maximum object count (if num_objects not given)')
42
+ parser.add_argument('--num_counterfactuals', type=int, default=2,
43
+ help='Number of counterfactual variants per scene')
44
+ parser.add_argument('--blender_path', type=str, default=None,
45
+ help='Path to Blender executable (auto-detected if not provided)')
46
+ parser.add_argument('--output_dir', type=str, default='output',
47
+ help='Base directory for all runs')
48
+ parser.add_argument('--run_name', type=str, default=None,
49
+ help='Optional custom name for this run')
50
+ parser.add_argument('--resume', action='store_true',
51
+ help='Resume from last checkpoint (requires --run_name)')
52
+ parser.add_argument('--cf_types', nargs='+',
53
+ choices=[
54
+ 'change_color', 'change_shape', 'change_size',
55
+ 'change_material', 'change_position',
56
+ 'add_object', 'remove_object', 'swap_attribute', 'occlusion_change', 'relational_flip',
57
+ 'replace_object',
58
+ 'change_background',
59
+ 'change_lighting', 'add_noise',
60
+ 'apply_fisheye', 'apply_blur', 'apply_vignette', 'apply_chromatic_aberration'
61
+ ],
62
+ help='Specific counterfactual types to use')
63
+ parser.add_argument('--semantic_only', action='store_true',
64
+ help='Generate only Semantic/Image counterfactuals (no Negative CFs)')
65
+ parser.add_argument('--negative_only', action='store_true',
66
+ help='Generate only Negative counterfactuals (no Semantic CFs)')
67
+ parser.add_argument('--same_cf_type', action='store_true',
68
+ help='Use the same counterfactual type for all variants')
69
+ parser.add_argument('--min_cf_change_score', type=float, default=1.0,
70
+ help='Minimum heuristic change score for counterfactuals')
71
+ parser.add_argument('--max_cf_attempts', type=int, default=10,
72
+ help='Max retries per counterfactual to meet --min_cf_change_score')
73
+ parser.add_argument('--min_noise_level', type=str, default='light',
74
+ choices=['light', 'medium', 'heavy'],
75
+ help='Minimum noise level when using add_noise counterfactual')
76
+ parser.add_argument('--list_cf_types', action='store_true',
77
+ help='List all available counterfactual types and exit')
78
+
79
+ args = parser.parse_args()
80
+
81
+ if args.list_cf_types:
82
+ list_counterfactual_types()
83
+ return
84
+
85
+ if args.resume and not args.run_name:
86
+ print("ERROR: --run_name is required when using --resume")
87
+ return
88
+
89
+ blender_path = args.blender_path or find_blender()
90
+ print(f"Using Blender: {blender_path}")
91
+
92
+ print("\nPreparing scripts...")
93
+ create_patched_render_script()
94
+
95
+ run_dir = create_run_directory(args.output_dir, args.run_name)
96
+ temp_run_id = os.path.basename(run_dir)
97
+ print(f"\n{'='*70}")
98
+ print(f"RUN DIRECTORY: {run_dir}")
99
+ print(f"{'='*70}")
100
+
101
+ scenes_dir = os.path.join(run_dir, 'scenes')
102
+ os.makedirs(scenes_dir, exist_ok=True)
103
+
104
+ checkpoint_file = os.path.join(run_dir, 'checkpoint.json')
105
+
106
+ completed_scenes = set()
107
+ if args.resume:
108
+ completed_scenes = load_checkpoint(checkpoint_file)
109
+ existing_scenes = get_completed_scenes_from_folder(scenes_dir)
110
+ completed_scenes.update(existing_scenes)
111
+
112
+ if completed_scenes:
113
+ print(f"\n[RESUME] Found {len(completed_scenes)} already completed scenes")
114
+ else:
115
+ print("\n[WARNING] Resume flag set but no checkpoint found, starting fresh")
116
+
117
+ print("\n" + "="*70)
118
+ print(f"GENERATING {args.num_scenes} SCENE SETS (JSON ONLY)")
119
+ print(f"Each with {args.num_counterfactuals} counterfactual variants")
120
+ print("="*70)
121
+
122
+ successful_scenes = 0
123
+
124
+ for i in range(args.num_scenes):
125
+ if i in completed_scenes:
126
+ print(f"\n[SKIP] Skipping scene {i} (already completed)")
127
+ successful_scenes += 1
128
+ continue
129
+
130
+ print(f"\n{'='*70}")
131
+ print(f"SCENE SET {i+1}/{args.num_scenes} (Scene #{i})")
132
+ print(f"{'='*70}")
133
+
134
+ if args.num_objects is not None:
135
+ num_objects = args.num_objects
136
+ else:
137
+ num_objects = random.randint(args.min_objects, args.max_objects)
138
+
139
+ base_scene = None
140
+ for retry in range(3):
141
+ base_scene = generate_base_scene(num_objects, blender_path, i, temp_run_dir=temp_run_id)
142
+ if base_scene and len(base_scene['objects']) > 0:
143
+ break
144
+ print(f" Retry {retry + 1}/3...")
145
+
146
+ if not base_scene or len(base_scene['objects']) == 0:
147
+ print(f" [FAILED] Failed to generate scene {i+1}")
148
+ continue
149
+
150
+ successful_scenes += 1
151
+
152
+ print(f" Creating {args.num_counterfactuals} counterfactuals...")
153
+ counterfactuals = generate_counterfactuals(
154
+ base_scene,
155
+ args.num_counterfactuals,
156
+ cf_types=args.cf_types,
157
+ same_cf_type=args.same_cf_type,
158
+ min_change_score=args.min_cf_change_score,
159
+ max_cf_attempts=args.max_cf_attempts,
160
+ min_noise_level=args.min_noise_level,
161
+ semantic_only=args.semantic_only,
162
+ negative_only=args.negative_only
163
+ )
164
+
165
+ for idx, cf in enumerate(counterfactuals):
166
+ cf_cat = cf.get('cf_category', 'unknown')
167
+ print(f" CF{idx+1} [{cf_cat}] ({cf['type']}): {cf['description']}")
168
+
169
+ scene_num = i + 1
170
+ scene_prefix = f"scene_{scene_num:04d}"
171
+ scene_paths = {'original': os.path.join(scenes_dir, f"{scene_prefix}_original.json")}
172
+
173
+ base_scene['cf_metadata'] = {
174
+ 'variant': 'original',
175
+ 'is_counterfactual': False,
176
+ 'cf_index': None,
177
+ 'cf_category': 'original',
178
+ 'cf_type': None,
179
+ 'cf_description': None,
180
+ 'source_scene': scene_prefix,
181
+ }
182
+ save_scene(base_scene, scene_paths['original'])
183
+
184
+ for idx, cf in enumerate(counterfactuals):
185
+ cf_name = f"cf{idx+1}"
186
+ scene_paths[cf_name] = os.path.join(scenes_dir, f"{scene_prefix}_{cf_name}.json")
187
+
188
+ cf_scene = cf['scene']
189
+ cf_scene['cf_metadata'] = {
190
+ 'variant': cf_name,
191
+ 'is_counterfactual': True,
192
+ 'cf_index': idx + 1,
193
+ 'cf_category': cf.get('cf_category', 'unknown'),
194
+ 'cf_type': cf.get('type', None),
195
+ 'cf_description': cf.get('description', None),
196
+ 'change_score': cf.get('change_score', None),
197
+ 'change_attempts': cf.get('change_attempts', None),
198
+ 'source_scene': scene_prefix,
199
+ }
200
+ save_scene(cf_scene, scene_paths[cf_name])
201
+
202
+ print(f" [OK] Saved {len(counterfactuals) + 1} scene files")
203
+
204
+ completed_scenes.add(i)
205
+ save_checkpoint(checkpoint_file, list(completed_scenes))
206
+
207
+ metadata = {
208
+ 'timestamp': datetime.now().isoformat(),
209
+ 'num_scenes': args.num_scenes,
210
+ 'num_counterfactuals': args.num_counterfactuals,
211
+ 'successful_scenes': successful_scenes,
212
+ 'successful_renders': 0,
213
+ 'cf_types': args.cf_types if args.cf_types else 'default',
214
+ 'semantic_only': args.semantic_only,
215
+ 'negative_only': args.negative_only,
216
+ }
217
+
218
+ metadata_path = os.path.join(run_dir, 'run_metadata.json')
219
+ with open(metadata_path, 'w') as f:
220
+ json.dump(metadata, f, indent=2)
221
+
222
+ temp_run_path = os.path.join(os.getcwd(), 'temp_output', temp_run_id)
223
+ if os.path.exists(temp_run_path):
224
+ shutil.rmtree(temp_run_path)
225
+ if os.path.exists('render_images_patched.py'):
226
+ os.remove('render_images_patched.py')
227
+
228
+ print("\n" + "="*70)
229
+ print("SCENE COMPLETE")
230
+ print("="*70)
231
+ print(f"Run directory: {run_dir}")
232
+ print(f"Successfully generated: {successful_scenes}/{args.num_scenes} scene sets")
233
+ print(f"\nOutput:")
234
+ print(f" Scene files: {scenes_dir}/")
235
+ print(f" Metadata: {metadata_path}")
236
+ print(f" Checkpoint: {checkpoint_file}")
237
+ print(f"\nNext step: Run 'python pipeline.py --render_only --run_name {args.run_name}' to render these scenes")
238
+ print("="*70)
239
+
240
+ if __name__ == '__main__':
241
+ main()
242
+
scripts/render.py ADDED
@@ -0,0 +1,1204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import print_function
2
+ import math, sys, random, argparse, json, os, tempfile
3
+ from datetime import datetime as dt
4
+ from collections import Counter
5
+ try:
6
+ from PIL import Image, ImageFilter
7
+ except ImportError:
8
+ Image = None
9
+ ImageFilter = None
10
+
11
+ INSIDE_BLENDER = True
12
+ try:
13
+ import bpy, bpy_extras
14
+ from mathutils import Vector
15
+ except ImportError as e:
16
+ INSIDE_BLENDER = False
17
+
18
+ def extract_args(input_argv=None):
19
+ if input_argv is None:
20
+ input_argv = sys.argv
21
+ output_argv = []
22
+ if '--' in input_argv:
23
+ idx = input_argv.index('--')
24
+ output_argv = input_argv[(idx + 1):]
25
+ return output_argv
26
+
27
+ def parse_args(parser, argv=None):
28
+ return parser.parse_args(extract_args(argv))
29
+
30
+ def delete_object(obj):
31
+ if not INSIDE_BLENDER:
32
+ return
33
+ bpy.ops.object.select_all(action='DESELECT')
34
+ obj.select_set(True)
35
+ bpy.context.view_layer.objects.active = obj
36
+ bpy.ops.object.delete()
37
+
38
+ def get_camera_coords(cam, pos):
39
+ if not INSIDE_BLENDER:
40
+ return (0, 0, 0)
41
+ scene = bpy.context.scene
42
+ x, y, z = bpy_extras.object_utils.world_to_camera_view(scene, cam, pos)
43
+ scale = scene.render.resolution_percentage / 100.0
44
+ w = int(scale * scene.render.resolution_x)
45
+ h = int(scale * scene.render.resolution_y)
46
+ px = int(round(x * w))
47
+ py = int(round(h - y * h))
48
+ return (px, py, z)
49
+
50
+ def set_layer(obj, layer_idx):
51
+ if not INSIDE_BLENDER:
52
+ return
53
+ obj.layers[layer_idx] = True
54
+ for i in range(len(obj.layers)):
55
+ obj.layers[i] = (i == layer_idx)
56
+
57
+ def add_object(object_dir, name, scale, loc, theta=0):
58
+ if not INSIDE_BLENDER:
59
+ return
60
+ object_dir = os.path.abspath(os.path.normpath(object_dir))
61
+ if not os.path.exists(object_dir):
62
+ print(f"ERROR: Object directory does not exist: {object_dir}")
63
+ return
64
+ count = 0
65
+ for obj in bpy.data.objects:
66
+ if obj.name.startswith(name):
67
+ count += 1
68
+
69
+ blend_file = os.path.join(object_dir, '%s.blend' % name)
70
+ blend_file = os.path.abspath(blend_file).replace('\\', '/')
71
+ if not os.path.exists(blend_file):
72
+ print(f"ERROR: Blend file does not exist: {blend_file}")
73
+ return
74
+ directory = blend_file + '/Object/'
75
+ try:
76
+ bpy.ops.wm.append(
77
+ directory=directory,
78
+ filename=name,
79
+ filter_blender=True
80
+ )
81
+ except Exception as e:
82
+ error_msg = str(e)
83
+ print(f"ERROR: Failed to load object {name} from {directory}: {error_msg}")
84
+ print(f" Error type: {type(e).__name__}")
85
+ raise
86
+
87
+ new_name = '%s_%d' % (name, count)
88
+ bpy.data.objects[name].name = new_name
89
+
90
+ x, y = loc
91
+ bpy.context.view_layer.objects.active = bpy.data.objects[new_name]
92
+ bpy.context.object.rotation_euler[2] = theta
93
+ bpy.ops.transform.resize(value=(scale, scale, scale))
94
+ bpy.ops.transform.translate(value=(x, y, scale))
95
+
96
+ def load_materials(material_dir):
97
+ if not INSIDE_BLENDER:
98
+ return
99
+ material_dir = os.path.abspath(os.path.normpath(material_dir))
100
+ if not os.path.exists(material_dir):
101
+ print(f"ERROR: Material directory does not exist: {material_dir}")
102
+ return
103
+ for fn in os.listdir(material_dir):
104
+ if not fn.endswith('.blend'): continue
105
+ name = os.path.splitext(fn)[0]
106
+ blend_file = os.path.join(material_dir, fn)
107
+ blend_file = os.path.abspath(blend_file).replace('\\', '/')
108
+ if not os.path.exists(blend_file):
109
+ print(f"ERROR: Blend file does not exist: {blend_file}")
110
+ continue
111
+ directory = blend_file + '/NodeTree/'
112
+ try:
113
+ bpy.ops.wm.append(
114
+ directory=directory,
115
+ filename=name,
116
+ filter_blender=True
117
+ )
118
+ except Exception as e:
119
+ error_msg = str(e)
120
+ print(f"ERROR: Failed to load material {name} from {directory}: {error_msg}")
121
+ print(f" Error type: {type(e).__name__}")
122
+ raise
123
+
124
+ def apply_filter_to_image(image_path, filter_type, filter_strength):
125
+ image_path = os.path.abspath(image_path)
126
+ if Image is None:
127
+ print(f"ERROR: PIL/Image not available, cannot apply filter {filter_type}")
128
+ return
129
+ if not os.path.exists(image_path):
130
+ print(f"ERROR: Image file does not exist: {image_path}")
131
+ return
132
+
133
+ try:
134
+ img = Image.open(image_path)
135
+
136
+ if filter_type == 'blur':
137
+ radius = max(1, int(filter_strength))
138
+ img = img.filter(ImageFilter.GaussianBlur(radius=radius))
139
+
140
+ elif filter_type == 'vignette':
141
+ width, height = img.size
142
+ center_x, center_y = width // 2, height // 2
143
+ max_dist = math.sqrt(center_x**2 + center_y**2)
144
+
145
+ img = img.convert('RGB')
146
+ pixels = img.load()
147
+ for y in range(height):
148
+ for x in range(width):
149
+ dist = math.sqrt((x - center_x)**2 + (y - center_y)**2)
150
+ factor = 1.0 - (dist / max_dist) * (filter_strength / 5.0)
151
+ factor = max(0.0, min(1.0, factor))
152
+
153
+ r, g, b = pixels[x, y]
154
+ pixels[x, y] = (int(r * factor), int(g * factor), int(b * factor))
155
+
156
+ elif filter_type == 'fisheye':
157
+ width, height = img.size
158
+ center_x, center_y = width / 2.0, height / 2.0
159
+ max_radius = min(center_x, center_y)
160
+
161
+ img = img.convert('RGB')
162
+ output = Image.new('RGB', (width, height))
163
+ out_pixels = output.load()
164
+ in_pixels = img.load()
165
+
166
+ for y in range(height):
167
+ for x in range(width):
168
+ dx = (x - center_x) / max_radius
169
+ dy = (y - center_y) / max_radius
170
+ distance = math.sqrt(dx*dx + dy*dy)
171
+
172
+ if distance > 1.0:
173
+ out_pixels[x, y] = (0, 0, 0)
174
+ else:
175
+ theta = math.atan2(dy, dx)
176
+ r_normalized = distance
177
+ r_distorted = r_normalized * (1.0 + filter_strength * (1.0 - r_normalized))
178
+ r_distorted = min(1.0, r_distorted)
179
+
180
+ src_x = int(center_x + r_distorted * max_radius * math.cos(theta))
181
+ src_y = int(center_y + r_distorted * max_radius * math.sin(theta))
182
+
183
+ if 0 <= src_x < width and 0 <= src_y < height:
184
+ out_pixels[x, y] = in_pixels[src_x, src_y]
185
+ else:
186
+ out_pixels[x, y] = (0, 0, 0)
187
+
188
+ img = output
189
+
190
+ img.save(image_path)
191
+ print(f"[OK] Applied {filter_type} filter (strength: {filter_strength:.2f})")
192
+ except Exception as e:
193
+ import traceback
194
+ print(f"ERROR applying filter {filter_type}: {e}")
195
+ traceback.print_exc()
196
+ raise
197
+
198
+ def add_material(name, **properties):
199
+ if not INSIDE_BLENDER:
200
+ return
201
+ mat_count = len(bpy.data.materials)
202
+ bpy.ops.material.new()
203
+ mat = bpy.data.materials['Material']
204
+ mat.name = 'Material_%d' % mat_count
205
+ obj = bpy.context.active_object
206
+ assert len(obj.data.materials) == 0
207
+ obj.data.materials.append(mat)
208
+
209
+ output_node = None
210
+ for n in mat.node_tree.nodes:
211
+ if n.name == 'Material Output':
212
+ output_node = n
213
+ break
214
+
215
+ group_node = mat.node_tree.nodes.new('ShaderNodeGroup')
216
+ group_node.node_tree = bpy.data.node_groups[name]
217
+
218
+ for inp in group_node.inputs:
219
+ if inp.name in properties:
220
+ inp.default_value = properties[inp.name]
221
+
222
+ mat.node_tree.links.new(
223
+ group_node.outputs['Shader'],
224
+ output_node.inputs['Surface'],
225
+ )
226
+
227
+ parser = argparse.ArgumentParser()
228
+ parser.add_argument('--scene_file', default=None,
229
+ help="Optional JSON file to load scene from. If provided, renders from JSON instead of generating random scenes.")
230
+ parser.add_argument('--base_scene_blendfile', default='data/base_scene.blend',
231
+ help="Base blender file on which all scenes are based; includes " +
232
+ "ground plane, lights, and camera.")
233
+ parser.add_argument('--properties_json', default='data/properties.json',
234
+ help="JSON file defining objects, materials, sizes, and colors. " +
235
+ "The \"colors\" field maps from CLEVR color names to RGB values; " +
236
+ "The \"sizes\" field maps from CLEVR size names to scalars used to " +
237
+ "rescale object models; the \"materials\" and \"shapes\" fields map " +
238
+ "from CLEVR material and shape names to .blend files in the " +
239
+ "--object_material_dir and --shape_dir directories respectively.")
240
+ parser.add_argument('--shape_dir', default='data/shapes',
241
+ help="Directory where .blend files for object models are stored")
242
+ parser.add_argument('--material_dir', default='data/materials',
243
+ help="Directory where .blend files for materials are stored")
244
+ parser.add_argument('--shape_color_combos_json', default=None,
245
+ help="Optional path to a JSON file mapping shape names to a list of " +
246
+ "allowed color names for that shape. This allows rendering images " +
247
+ "for CLEVR-CoGenT.")
248
+
249
+ parser.add_argument('--min_objects', default=3, type=int,
250
+ help="The minimum number of objects to place in each scene")
251
+ parser.add_argument('--max_objects', default=10, type=int,
252
+ help="The maximum number of objects to place in each scene")
253
+ parser.add_argument('--min_dist', default=0.15, type=float,
254
+ help="The minimum allowed distance between object centers")
255
+ parser.add_argument('--margin', default=0.2, type=float,
256
+ help="Along all cardinal directions (left, right, front, back), all " +
257
+ "objects will be at least this distance apart. This makes resolving " +
258
+ "spatial relationships slightly less ambiguous.")
259
+ parser.add_argument('--min_pixels_per_object', default=50, type=int,
260
+ help="All objects will have at least this many visible pixels in the " +
261
+ "final rendered images; this ensures that no objects are fully " +
262
+ "occluded by other objects.")
263
+ parser.add_argument('--max_retries', default=100, type=int,
264
+ help="The number of times to try placing an object before giving up and " +
265
+ "re-placing all objects in the scene.")
266
+
267
+ parser.add_argument('--start_idx', default=0, type=int,
268
+ help="The index at which to start for numbering rendered images. Setting " +
269
+ "this to non-zero values allows you to distribute rendering across " +
270
+ "multiple machines and recombine the results later.")
271
+ parser.add_argument('--num_images', default=5, type=int,
272
+ help="The number of images to render")
273
+ parser.add_argument('--filename_prefix', default='CLEVR',
274
+ help="This prefix will be prepended to the rendered images and JSON scenes")
275
+ parser.add_argument('--split', default='new',
276
+ help="Name of the split for which we are rendering. This will be added to " +
277
+ "the names of rendered images, and will also be stored in the JSON " +
278
+ "scene structure for each image.")
279
+ parser.add_argument('--output_image_dir', default='../output/images/',
280
+ help="The directory where output images will be stored. It will be " +
281
+ "created if it does not exist.")
282
+ parser.add_argument('--output_scene_dir', default='../output/scenes/',
283
+ help="The directory where output JSON scene structures will be stored. " +
284
+ "It will be created if it does not exist.")
285
+ parser.add_argument('--output_scene_file', default='../output/CLEVR_scenes.json',
286
+ help="Path to write a single JSON file containing all scene information")
287
+ parser.add_argument('--output_blend_dir', default='output/blendfiles',
288
+ help="The directory where blender scene files will be stored, if the " +
289
+ "user requested that these files be saved using the " +
290
+ "--save_blendfiles flag; in this case it will be created if it does " +
291
+ "not already exist.")
292
+ parser.add_argument('--save_blendfiles', type=int, default=0,
293
+ help="Setting --save_blendfiles 1 will cause the blender scene file for " +
294
+ "each generated image to be stored in the directory specified by " +
295
+ "the --output_blend_dir flag. These files are not saved by default " +
296
+ "because they take up ~5-10MB each.")
297
+ parser.add_argument('--version', default='1.0',
298
+ help="String to store in the \"version\" field of the generated JSON file")
299
+ parser.add_argument('--license',
300
+ default="Creative Commons Attribution (CC-BY 4.0)",
301
+ help="String to store in the \"license\" field of the generated JSON file")
302
+ parser.add_argument('--date', default=dt.today().strftime("%m/%d/%Y"),
303
+ help="String to store in the \"date\" field of the generated JSON file; " +
304
+ "defaults to today's date")
305
+
306
+ parser.add_argument('--use_gpu', default=0, type=int,
307
+ help="Setting --use_gpu 1 enables GPU-accelerated rendering using CUDA. " +
308
+ "You must have an NVIDIA GPU with the CUDA toolkit installed for " +
309
+ "to work.")
310
+ parser.add_argument('--width', default=320, type=int,
311
+ help="The width (in pixels) for the rendered images")
312
+ parser.add_argument('--height', default=240, type=int,
313
+ help="The height (in pixels) for the rendered images")
314
+ parser.add_argument('--key_light_jitter', default=1.0, type=float,
315
+ help="The magnitude of random jitter to add to the key light position.")
316
+ parser.add_argument('--fill_light_jitter', default=1.0, type=float,
317
+ help="The magnitude of random jitter to add to the fill light position.")
318
+ parser.add_argument('--back_light_jitter', default=1.0, type=float,
319
+ help="The magnitude of random jitter to add to the back light position.")
320
+ parser.add_argument('--camera_jitter', default=0.5, type=float,
321
+ help="The magnitude of random jitter to add to the camera position")
322
+ parser.add_argument('--render_num_samples', default=512, type=int,
323
+ help="The number of samples to use when rendering. Larger values will " +
324
+ "result in nicer images but will cause rendering to take longer.")
325
+ parser.add_argument('--render_min_bounces', default=8, type=int,
326
+ help="The minimum number of bounces to use for rendering.")
327
+ parser.add_argument('--render_max_bounces', default=8, type=int,
328
+ help="The maximum number of bounces to use for rendering.")
329
+ parser.add_argument('--render_tile_size', default=256, type=int,
330
+ help="The tile size to use for rendering. This should not affect the " +
331
+ "quality of the rendered image but may affect the speed; CPU-based " +
332
+ "rendering may achieve better performance using smaller tile sizes " +
333
+ "while larger tile sizes may be optimal for GPU-based rendering.")
334
+ parser.add_argument('--output_image', default=None,
335
+ help="Output image path (used when rendering from JSON)")
336
+
337
+ MIN_VISIBLE_FRACTION = 0.001
338
+ MIN_VISIBLE_FRACTION_PARTIAL_OCCLUSION = 0.0005
339
+ MIN_PIXELS_FLOOR = 50
340
+
341
+ BASE_MIN_VISIBILITY_FRACTION = 0.9
342
+ CF_OCCLUSION_MIN_VISIBILITY_FRACTION = 0.3
343
+ CF_OCCLUSION_MAX_VISIBILITY_FRACTION = 0.5
344
+ CF_OCCLUSION_HARD_MIN_FRACTION = 0.2
345
+
346
+
347
+ def min_visible_pixels(width, height, fraction=MIN_VISIBLE_FRACTION, floor=MIN_PIXELS_FLOOR):
348
+ return max(floor, int(width * height * fraction))
349
+
350
+
351
+ BACKGROUND_COLORS = {
352
+ 'default': None,
353
+ 'gray': (0.5, 0.5, 0.5),
354
+ 'blue': (0.2, 0.4, 0.8),
355
+ 'green': (0.2, 0.6, 0.3),
356
+ 'brown': (0.4, 0.3, 0.2),
357
+ 'purple': (0.5, 0.3, 0.6),
358
+ 'orange': (0.8, 0.5, 0.2),
359
+ 'white': (0.9, 0.9, 0.9),
360
+ 'dark_gray': (0.2, 0.2, 0.2),
361
+ 'red': (0.7, 0.2, 0.2),
362
+ 'yellow': (0.8, 0.8, 0.3),
363
+ 'cyan': (0.3, 0.7, 0.8),
364
+ }
365
+
366
+ LIGHTING_PRESETS = {
367
+ 'default': {'key': 1.0, 'fill': 0.5, 'back': 0.3},
368
+ 'bright': {'key': 12.0, 'fill': 6.0, 'back': 4.0},
369
+ 'dim': {'key': 0.008, 'fill': 0.004, 'back': 0.002},
370
+ 'warm': {'key': 5.0, 'fill': 0.8, 'back': 0.3, 'color': (1.0, 0.5, 0.2)},
371
+ 'cool': {'key': 4.0, 'fill': 2.0, 'back': 1.5, 'color': (0.2, 0.5, 1.0)},
372
+ 'dramatic': {'key': 15.0, 'fill': 0.005, 'back': 0.002},
373
+ }
374
+
375
+ def set_background_color(color_name):
376
+ """Set the world background color"""
377
+ if not INSIDE_BLENDER:
378
+ return
379
+ if color_name not in BACKGROUND_COLORS or BACKGROUND_COLORS[color_name] is None:
380
+ return
381
+
382
+ rgb = BACKGROUND_COLORS[color_name]
383
+
384
+ world = bpy.context.scene.world
385
+ if world is None:
386
+ world = bpy.data.worlds.new("World")
387
+ bpy.context.scene.world = world
388
+
389
+ world.use_nodes = True
390
+ nodes = world.node_tree.nodes
391
+
392
+ bg_node = None
393
+ for node in nodes:
394
+ if node.type == 'BACKGROUND':
395
+ bg_node = node
396
+ break
397
+
398
+ if bg_node is None:
399
+ bg_node = nodes.new(type='ShaderNodeBackground')
400
+
401
+ bg_node.inputs['Color'].default_value = (rgb[0], rgb[1], rgb[2], 1.0)
402
+ bg_node.inputs['Strength'].default_value = 1.0
403
+
404
+ print(f"Set background color to {color_name}: RGB{rgb}")
405
+
406
+ def set_ground_color(color_name):
407
+ if not INSIDE_BLENDER:
408
+ return
409
+ if color_name not in BACKGROUND_COLORS or BACKGROUND_COLORS[color_name] is None:
410
+ return
411
+
412
+ rgb = BACKGROUND_COLORS[color_name]
413
+
414
+ ground = None
415
+ for obj in bpy.data.objects:
416
+ if 'ground' in obj.name.lower() or 'plane' in obj.name.lower():
417
+ ground = obj
418
+ break
419
+
420
+ if ground is None:
421
+ return
422
+
423
+ if len(ground.data.materials) == 0:
424
+ mat = bpy.data.materials.new(name="Ground_Material")
425
+ ground.data.materials.append(mat)
426
+ else:
427
+ mat = ground.data.materials[0]
428
+
429
+ mat.use_nodes = True
430
+ nodes = mat.node_tree.nodes
431
+
432
+ bsdf = None
433
+ for node in nodes:
434
+ if node.type == 'BSDF_PRINCIPLED':
435
+ bsdf = node
436
+ break
437
+
438
+ if bsdf:
439
+ bsdf.inputs['Base Color'].default_value = (rgb[0], rgb[1], rgb[2], 1.0)
440
+ print(f"Set ground color to {color_name}")
441
+
442
+ def set_lighting(lighting_name):
443
+ """Set lighting conditions"""
444
+ if not INSIDE_BLENDER:
445
+ return
446
+ if lighting_name not in LIGHTING_PRESETS:
447
+ return
448
+
449
+ preset = LIGHTING_PRESETS[lighting_name]
450
+
451
+ lamp_names = ['Lamp_Key', 'Lamp_Fill', 'Lamp_Back']
452
+ intensity_keys = ['key', 'fill', 'back']
453
+
454
+ for lamp_name, int_key in zip(lamp_names, intensity_keys):
455
+ if lamp_name in bpy.data.objects:
456
+ lamp_obj = bpy.data.objects[lamp_name]
457
+ if lamp_obj.data and hasattr(lamp_obj.data, 'energy'):
458
+ base_energy = lamp_obj.data.energy
459
+ lamp_obj.data.energy = base_energy * preset.get(int_key, 1.0)
460
+
461
+ if 'color' in preset and hasattr(lamp_obj.data, 'color'):
462
+ lamp_obj.data.color = preset['color']
463
+
464
+ print(f"Set lighting to {lighting_name}")
465
+
466
+ def render_from_json(args):
467
+ if not INSIDE_BLENDER:
468
+ print("ERROR: render_from_json must be run inside Blender")
469
+ return
470
+
471
+ output_dir = os.path.dirname(args.output_image) if args.output_image else '.'
472
+ if output_dir and not os.path.exists(output_dir):
473
+ os.makedirs(output_dir)
474
+
475
+ with open(args.scene_file, 'r') as f:
476
+ scene_struct = json.load(f)
477
+
478
+ num_objects = len(scene_struct.get('objects', []))
479
+ print(f"Scene has {num_objects} objects")
480
+
481
+ base_scene_path = os.path.abspath(args.base_scene_blendfile)
482
+ bpy.ops.wm.open_mainfile(filepath=base_scene_path)
483
+
484
+ try:
485
+ load_materials(args.material_dir)
486
+ except Exception as e:
487
+ print(f"Warning: Could not load materials: {e}")
488
+
489
+ background_color = scene_struct.get('background_color', None)
490
+ if background_color:
491
+ set_background_color(background_color)
492
+ set_ground_color(background_color)
493
+
494
+ lighting = scene_struct.get('lighting', None)
495
+ if lighting:
496
+ set_lighting(lighting)
497
+
498
+ render_args = bpy.context.scene.render
499
+ render_args.engine = "CYCLES"
500
+ render_args.filepath = args.output_image
501
+ render_args.resolution_x = args.width
502
+ render_args.resolution_y = args.height
503
+ render_args.resolution_percentage = 100
504
+
505
+ if args.use_gpu == 1:
506
+ try:
507
+ bpy.context.preferences.addons['cycles'].preferences.compute_device_type = 'CUDA'
508
+ bpy.context.scene.cycles.device = 'GPU'
509
+ print("[OK] GPU rendering enabled")
510
+ except Exception as e:
511
+ print(f"Warning: Could not enable GPU: {e}")
512
+
513
+ bpy.context.scene.cycles.samples = args.render_num_samples
514
+
515
+ filter_type = scene_struct.get('filter_type')
516
+ filter_strength = scene_struct.get('filter_strength', 1.0)
517
+
518
+ if filter_type == 'fisheye':
519
+ camera = bpy.data.objects.get('Camera')
520
+ if camera and camera.data:
521
+ cam_data = camera.data
522
+ if cam_data.type == 'PERSP':
523
+ cam_data.lens = cam_data.lens * 0.7
524
+ print(f"[OK] Zoomed out camera for fisheye: lens={cam_data.lens:.1f}mm")
525
+
526
+ with open(args.properties_json, 'r') as f:
527
+ properties = json.load(f)
528
+ color_name_to_rgba = {}
529
+ for name, rgb in properties['colors'].items():
530
+ rgba = [float(c) / 255.0 for c in rgb] + [1.0]
531
+ color_name_to_rgba[name] = rgba
532
+ size_mapping = properties['sizes']
533
+
534
+ shape_semantic_to_file = properties['shapes']
535
+ material_semantic_to_file = properties['materials']
536
+
537
+ blender_objects = []
538
+ print("Adding objects to scene...")
539
+ for i, obj_info in enumerate(scene_struct.get('objects', [])):
540
+ x, y, z = obj_info['3d_coords']
541
+ r = size_mapping[obj_info['size']]
542
+ semantic_shape = obj_info['shape']
543
+
544
+ if semantic_shape == 'cube':
545
+ r /= math.sqrt(2)
546
+
547
+ if semantic_shape not in shape_semantic_to_file:
548
+ print(f"ERROR: Shape '{semantic_shape}' not found")
549
+ continue
550
+
551
+ shape_file_name = shape_semantic_to_file[semantic_shape]
552
+
553
+ try:
554
+ add_object(args.shape_dir, shape_file_name, r, (x, y), theta=obj_info['rotation'])
555
+ except Exception as e:
556
+ print(f"Error adding object {i}: {e}")
557
+ continue
558
+ if INSIDE_BLENDER and bpy.context.object:
559
+ blender_objects.append(bpy.context.object)
560
+
561
+ rgba = color_name_to_rgba[obj_info['color']]
562
+ semantic_material = obj_info['material']
563
+
564
+ if semantic_material not in material_semantic_to_file:
565
+ print(f"ERROR: Material '{semantic_material}' not found")
566
+ continue
567
+
568
+ mat_file_name = material_semantic_to_file[semantic_material]
569
+
570
+ try:
571
+ add_material(mat_file_name, Color=rgba)
572
+ except Exception as e:
573
+ print(f"Warning: Could not add material: {e}")
574
+
575
+ if blender_objects:
576
+ cf_meta = scene_struct.get('cf_metadata') or {}
577
+ cf_type = cf_meta.get('cf_type', '')
578
+
579
+ visibility_info = None
580
+ if INSIDE_BLENDER and Image is not None:
581
+ try:
582
+ visibility_info = compute_visibility_fractions(blender_objects)
583
+ except Exception as e:
584
+ print(f"Warning: compute_visibility_fractions failed during render: {e}")
585
+ visibility_info = None
586
+
587
+ all_visible = True
588
+ fail_reason = 'unknown visibility failure'
589
+
590
+ if visibility_info is not None:
591
+ ratios, scene_counts, full_counts = visibility_info
592
+
593
+ if cf_type == 'occlusion_change':
594
+ too_hidden = [
595
+ (i, r) for i, r in enumerate(ratios)
596
+ if full_counts[i] > 0 and r < CF_OCCLUSION_HARD_MIN_FRACTION
597
+ ]
598
+ band_objects = [
599
+ (i, r) for i, r in enumerate(ratios)
600
+ if full_counts[i] > 0 and CF_OCCLUSION_MIN_VISIBILITY_FRACTION <= r <= CF_OCCLUSION_MAX_VISIBILITY_FRACTION
601
+ ]
602
+
603
+ if too_hidden:
604
+ all_visible = False
605
+ min_r = min(r for (_, r) in too_hidden)
606
+ fail_reason = (f'at least one object is too occluded in occlusion_change CF; '
607
+ f'min visibility fraction={min_r:.3f} '
608
+ f'(required >= {CF_OCCLUSION_HARD_MIN_FRACTION})')
609
+ elif not band_objects:
610
+ all_visible = False
611
+ fail_reason = (f'no object falls into required occlusion band '
612
+ f'[{CF_OCCLUSION_MIN_VISIBILITY_FRACTION}, '
613
+ f'{CF_OCCLUSION_MAX_VISIBILITY_FRACTION}]')
614
+ else:
615
+ all_visible = True
616
+
617
+ else:
618
+ too_occluded = [
619
+ (i, r) for i, r in enumerate(ratios)
620
+ if full_counts[i] > 0 and r < BASE_MIN_VISIBILITY_FRACTION
621
+ ]
622
+ if too_occluded:
623
+ all_visible = False
624
+ min_r = min(r for (_, r) in too_occluded)
625
+ fail_reason = (f'at least one object is too occluded in base scene; '
626
+ f'min visibility fraction={min_r:.3f} '
627
+ f'(required >= {BASE_MIN_VISIBILITY_FRACTION})')
628
+ else:
629
+ all_visible = True
630
+
631
+ else:
632
+ # Fallback to legacy absolute pixel-based visibility when we cannot
633
+ # compute per-object relative visibility (e.g., PIL not available).
634
+ w = getattr(args, 'width', 320)
635
+ h = getattr(args, 'height', 240)
636
+ if cf_type == 'occlusion_change':
637
+ min_pixels = min_visible_pixels(w, h, MIN_VISIBLE_FRACTION_PARTIAL_OCCLUSION, MIN_PIXELS_FLOOR)
638
+ else:
639
+ base = min_visible_pixels(w, h, MIN_VISIBLE_FRACTION, MIN_PIXELS_FLOOR)
640
+ min_pixels = max(getattr(args, 'min_pixels_per_object', MIN_PIXELS_FLOOR), base)
641
+ all_visible = check_visibility(blender_objects, min_pixels)
642
+ if not all_visible:
643
+ fail_reason = 'at least one object has too few visible pixels'
644
+
645
+ if not all_visible:
646
+ print(f'Visibility check failed: {fail_reason}')
647
+ for obj in blender_objects:
648
+ try:
649
+ delete_object(obj)
650
+ except Exception:
651
+ pass
652
+ sys.exit(1)
653
+
654
+ filter_type = scene_struct.get('filter_type')
655
+ filter_strength = scene_struct.get('filter_strength', 1.0)
656
+
657
+ print(f"Rendering to {args.output_image}...")
658
+
659
+ try:
660
+ bpy.ops.render.render(write_still=True)
661
+ print("[OK] Rendering complete!")
662
+ except Exception as e:
663
+ print(f"Error during rendering: {e}")
664
+ sys.exit(1)
665
+
666
+ post_filter_type = scene_struct.get('filter_type')
667
+ if post_filter_type and post_filter_type != 'fisheye':
668
+ if Image is None:
669
+ print(f"Warning: PIL not available, cannot apply post-filter {post_filter_type}")
670
+ elif not os.path.exists(args.output_image):
671
+ print(f"Warning: Output image does not exist: {args.output_image}")
672
+ else:
673
+ try:
674
+ post_filter_strength = scene_struct.get('filter_strength', 1.0)
675
+ apply_filter_to_image(args.output_image, post_filter_type, post_filter_strength)
676
+ except Exception as e:
677
+ import traceback
678
+ print(f"Warning: Failed to apply post-filter {post_filter_type}: {e}")
679
+ traceback.print_exc()
680
+
681
+ def main(args):
682
+ if args.scene_file:
683
+ render_from_json(args)
684
+ return
685
+
686
+ num_digits = 6
687
+ prefix = '%s_%s_' % (args.filename_prefix, args.split)
688
+ img_template = '%s%%0%dd.png' % (prefix, num_digits)
689
+ scene_template = '%s%%0%dd.json' % (prefix, num_digits)
690
+ blend_template = '%s%%0%dd.blend' % (prefix, num_digits)
691
+ img_template = os.path.join(args.output_image_dir, img_template)
692
+ scene_template = os.path.join(args.output_scene_dir, scene_template)
693
+ blend_template = os.path.join(args.output_blend_dir, blend_template)
694
+
695
+ if not os.path.isdir(args.output_image_dir):
696
+ os.makedirs(args.output_image_dir)
697
+ if not os.path.isdir(args.output_scene_dir):
698
+ os.makedirs(args.output_scene_dir)
699
+ if args.save_blendfiles == 1 and not os.path.isdir(args.output_blend_dir):
700
+ os.makedirs(args.output_blend_dir)
701
+
702
+ all_scene_paths = []
703
+ for i in range(args.num_images):
704
+ img_path = img_template % (i + args.start_idx)
705
+ scene_path = scene_template % (i + args.start_idx)
706
+ all_scene_paths.append(scene_path)
707
+ blend_path = None
708
+ if args.save_blendfiles == 1:
709
+ blend_path = blend_template % (i + args.start_idx)
710
+ num_objects = random.randint(args.min_objects, args.max_objects)
711
+ render_scene(args,
712
+ num_objects=num_objects,
713
+ output_index=(i + args.start_idx),
714
+ output_split=args.split,
715
+ output_image=img_path,
716
+ output_scene=scene_path,
717
+ output_blendfile=blend_path,
718
+ )
719
+
720
+ all_scenes = []
721
+ for scene_path in all_scene_paths:
722
+ with open(scene_path, 'r') as f:
723
+ all_scenes.append(json.load(f))
724
+ output = {
725
+ 'info': {
726
+ 'date': args.date,
727
+ 'version': args.version,
728
+ 'split': args.split,
729
+ 'license': args.license,
730
+ },
731
+ 'scenes': all_scenes
732
+ }
733
+ if args.output_scene_file:
734
+ output_dir = os.path.dirname(os.path.abspath(args.output_scene_file))
735
+ if output_dir:
736
+ os.makedirs(output_dir, exist_ok=True)
737
+ with open(args.output_scene_file, 'w') as f:
738
+ json.dump(output, f)
739
+
740
+
741
+
742
+ def render_scene(args,
743
+ num_objects=5,
744
+ output_index=0,
745
+ output_split='none',
746
+ output_image='render.png',
747
+ output_scene='render_json',
748
+ output_blendfile=None,
749
+ ):
750
+
751
+ base_scene_path = os.path.abspath(args.base_scene_blendfile)
752
+ bpy.ops.wm.open_mainfile(filepath=base_scene_path)
753
+ load_materials(args.material_dir)
754
+
755
+ render_args = bpy.context.scene.render
756
+ render_args.engine = "CYCLES"
757
+ render_args.filepath = output_image
758
+ render_args.resolution_x = args.width
759
+ render_args.resolution_y = args.height
760
+ render_args.resolution_percentage = 100
761
+ if args.use_gpu == 1:
762
+ bpy.context.preferences.addons['cycles'].preferences.compute_device_type = 'CUDA'
763
+ bpy.context.preferences.addons['cycles'].preferences.get_devices()
764
+ for device in bpy.context.preferences.addons['cycles'].preferences.devices:
765
+ device.use = True
766
+
767
+ bpy.data.worlds['World'].cycles.sample_as_light = True
768
+ bpy.context.scene.cycles.blur_glossy = 2.0
769
+ bpy.context.scene.cycles.samples = args.render_num_samples
770
+ bpy.context.scene.cycles.transparent_min_bounces = args.render_min_bounces
771
+ bpy.context.scene.cycles.transparent_max_bounces = args.render_max_bounces
772
+ if args.use_gpu == 1:
773
+ bpy.context.scene.cycles.device = 'GPU'
774
+
775
+ scene_struct = {
776
+ 'split': output_split,
777
+ 'image_index': output_index,
778
+ 'image_filename': os.path.basename(output_image),
779
+ 'objects': [],
780
+ 'directions': {},
781
+ }
782
+
783
+ bpy.ops.mesh.primitive_plane_add(size=10, location=(0, 0, 0))
784
+ plane = bpy.context.object
785
+
786
+ def rand(L):
787
+ return 2.0 * L * (random.random() - 0.5)
788
+
789
+ if args.camera_jitter > 0:
790
+ for i in range(3):
791
+ bpy.data.objects['Camera'].location[i] += rand(args.camera_jitter)
792
+
793
+ camera = bpy.data.objects['Camera']
794
+ plane_normal = plane.data.vertices[0].normal
795
+ cam_behind = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))
796
+ cam_left = camera.matrix_world.to_quaternion() @ Vector((-1, 0, 0))
797
+ cam_up = camera.matrix_world.to_quaternion() @ Vector((0, 1, 0))
798
+ plane_behind = (cam_behind - cam_behind.project(plane_normal)).normalized()
799
+ plane_left = (cam_left - cam_left.project(plane_normal)).normalized()
800
+ plane_up = cam_up.project(plane_normal).normalized()
801
+
802
+ delete_object(plane)
803
+
804
+ scene_struct['directions']['behind'] = tuple(plane_behind)
805
+ scene_struct['directions']['front'] = tuple(-plane_behind)
806
+ scene_struct['directions']['left'] = tuple(plane_left)
807
+ scene_struct['directions']['right'] = tuple(-plane_left)
808
+ scene_struct['directions']['above'] = tuple(plane_up)
809
+ scene_struct['directions']['below'] = tuple(-plane_up)
810
+
811
+ if args.key_light_jitter > 0:
812
+ for i in range(3):
813
+ bpy.data.objects['Lamp_Key'].location[i] += rand(args.key_light_jitter)
814
+ if args.back_light_jitter > 0:
815
+ for i in range(3):
816
+ bpy.data.objects['Lamp_Back'].location[i] += rand(args.back_light_jitter)
817
+ if args.fill_light_jitter > 0:
818
+ for i in range(3):
819
+ bpy.data.objects['Lamp_Fill'].location[i] += rand(args.fill_light_jitter)
820
+
821
+ objects, blender_objects = add_random_objects(scene_struct, num_objects, args, camera)
822
+ scene_struct['objects'] = objects
823
+ scene_struct['relationships'] = compute_all_relationships(scene_struct)
824
+ while True:
825
+ try:
826
+ bpy.ops.render.render(write_still=True)
827
+ break
828
+ except Exception as e:
829
+ print(e)
830
+
831
+ with open(output_scene, 'w') as f:
832
+ json.dump(scene_struct, f, indent=2)
833
+
834
+ if output_blendfile is not None:
835
+ bpy.ops.wm.save_as_mainfile(filepath=output_blendfile)
836
+
837
+
838
+ def add_random_objects(scene_struct, num_objects, args, camera, max_scene_attempts=10):
839
+ scene_attempt = 0
840
+ while scene_attempt < max_scene_attempts:
841
+ scene_attempt += 1
842
+
843
+ with open(args.properties_json, 'r') as f:
844
+ properties = json.load(f)
845
+ color_name_to_rgba = {}
846
+ for name, rgb in properties['colors'].items():
847
+ rgba = [float(c) / 255.0 for c in rgb] + [1.0]
848
+ color_name_to_rgba[name] = rgba
849
+ material_mapping = [(v, k) for k, v in properties['materials'].items()]
850
+ object_mapping = [(v, k) for k, v in properties['shapes'].items()]
851
+ size_mapping = list(properties['sizes'].items())
852
+
853
+ shape_color_combos = None
854
+ if args.shape_color_combos_json is not None:
855
+ with open(args.shape_color_combos_json, 'r') as f:
856
+ shape_color_combos = list(json.load(f).items())
857
+
858
+ positions = []
859
+ objects = []
860
+ blender_objects = []
861
+ for i in range(num_objects):
862
+ size_name, r = random.choice(size_mapping)
863
+
864
+ num_tries = 0
865
+ while True:
866
+ num_tries += 1
867
+ if num_tries > args.max_retries:
868
+ for obj in blender_objects:
869
+ delete_object(obj)
870
+ break
871
+ x = random.uniform(-3, 3)
872
+ y = random.uniform(-3, 3)
873
+ dists_good = True
874
+ margins_good = True
875
+ for (xx, yy, rr) in positions:
876
+ dx, dy = x - xx, y - yy
877
+ dist = math.sqrt(dx * dx + dy * dy)
878
+ if dist - r - rr < args.min_dist:
879
+ dists_good = False
880
+ break
881
+ for direction_name in ['left', 'right', 'front', 'behind']:
882
+ direction_vec = scene_struct['directions'][direction_name]
883
+ assert direction_vec[2] == 0
884
+ margin = dx * direction_vec[0] + dy * direction_vec[1]
885
+ if 0 < margin < args.margin:
886
+ print(margin, args.margin, direction_name)
887
+ print('BROKEN MARGIN!')
888
+ margins_good = False
889
+ break
890
+ if not margins_good:
891
+ break
892
+
893
+ if dists_good and margins_good:
894
+ break
895
+
896
+ if num_tries > args.max_retries:
897
+ break
898
+
899
+ if shape_color_combos is None:
900
+ obj_name, obj_name_out = random.choice(object_mapping)
901
+ color_name, rgba = random.choice(list(color_name_to_rgba.items()))
902
+ else:
903
+ obj_name_out, color_choices = random.choice(shape_color_combos)
904
+ color_name = random.choice(color_choices)
905
+ obj_name = [k for k, v in object_mapping if v == obj_name_out][0]
906
+ rgba = color_name_to_rgba[color_name]
907
+
908
+ if obj_name == 'Cube':
909
+ r /= math.sqrt(2)
910
+
911
+ theta = 360.0 * random.random()
912
+ add_object(args.shape_dir, obj_name, r, (x, y), theta=theta)
913
+ obj = bpy.context.object
914
+ blender_objects.append(obj)
915
+ positions.append((x, y, r))
916
+
917
+ mat_name, mat_name_out = random.choice(material_mapping)
918
+ add_material(mat_name, Color=rgba)
919
+
920
+ pixel_coords = get_camera_coords(camera, obj.location)
921
+ objects.append({
922
+ 'shape': obj_name_out,
923
+ 'size': size_name,
924
+ 'material': mat_name_out,
925
+ '3d_coords': tuple(obj.location),
926
+ 'rotation': theta,
927
+ 'pixel_coords': pixel_coords,
928
+ 'color': color_name,
929
+ })
930
+
931
+ if len(objects) < num_objects:
932
+ continue
933
+
934
+ visibility_info = None
935
+ if INSIDE_BLENDER and Image is not None:
936
+ try:
937
+ visibility_info = compute_visibility_fractions(blender_objects)
938
+ except Exception as e:
939
+ print(f"Warning: compute_visibility_fractions failed during scene generation: {e}")
940
+ visibility_info = None
941
+
942
+ all_visible = True
943
+ if visibility_info is not None:
944
+ ratios, scene_counts, full_counts = visibility_info
945
+ min_ratio = min((r for r in ratios if full_counts[ratios.index(r)] > 0), default=1.0)
946
+ all_visible = all(
947
+ (full_counts[i] == 0) or (ratios[i] >= BASE_MIN_VISIBILITY_FRACTION)
948
+ for i in range(len(ratios))
949
+ )
950
+ if not all_visible:
951
+ print(f'Some objects are too occluded in generated scene; '
952
+ f'min visibility fraction={min_ratio:.3f} (required >= {BASE_MIN_VISIBILITY_FRACTION})')
953
+ else:
954
+ # Fallback to legacy absolute pixel-based visibility when PIL or Blender context is unavailable.
955
+ min_pixels = max(args.min_pixels_per_object, min_visible_pixels(args.width, args.height))
956
+ all_visible = check_visibility(blender_objects, min_pixels)
957
+
958
+ if not all_visible:
959
+ print('Some objects are occluded; replacing objects')
960
+ for obj in blender_objects:
961
+ delete_object(obj)
962
+ continue
963
+
964
+ return objects, blender_objects
965
+
966
+ raise RuntimeError(f"Failed to generate a valid scene after {max_scene_attempts} attempts")
967
+
968
+
969
+ def compute_all_relationships(scene_struct, eps=0.2):
970
+ """
971
+ Computes relationships between all pairs of objects in the scene.
972
+
973
+ Returns a dictionary mapping string relationship names to lists of lists of
974
+ integers, where output[rel][i] gives a list of object indices that have the
975
+ relationship rel with object i. For example if j is in output['left'][i] then
976
+ object j is left of object j.
977
+ """
978
+ all_relationships = {}
979
+ for name, direction_vec in scene_struct['directions'].items():
980
+ if name == 'above' or name == 'below': continue
981
+ all_relationships[name] = []
982
+ for i, obj1 in enumerate(scene_struct['objects']):
983
+ coords1 = obj1['3d_coords']
984
+ related = set()
985
+ for j, obj2 in enumerate(scene_struct['objects']):
986
+ if obj1 == obj2: continue
987
+ coords2 = obj2['3d_coords']
988
+ diff = [coords2[k] - coords1[k] for k in [0, 1, 2]]
989
+ dot = sum(diff[k] * direction_vec[k] for k in [0, 1, 2])
990
+ if dot > eps:
991
+ related.add(j)
992
+ all_relationships[name].append(sorted(list(related)))
993
+ return all_relationships
994
+
995
+
996
+ def compute_visibility_fractions(blender_objects):
997
+ if not INSIDE_BLENDER or not blender_objects:
998
+ return None
999
+ if Image is None:
1000
+ return None
1001
+
1002
+ # First pass: all objects together (occluded counts).
1003
+ fd, path = tempfile.mkstemp(suffix='.png')
1004
+ os.close(fd)
1005
+ try:
1006
+ colors_list = render_shadeless(blender_objects, path, use_distinct_colors=True)
1007
+ img = Image.open(path).convert('RGB')
1008
+ w, h = img.size
1009
+ pix = img.load()
1010
+ color_to_idx = {}
1011
+ for i, (r, g, b) in enumerate(colors_list):
1012
+ key = (round(r * 255), round(g * 255), round(b * 255))
1013
+ color_to_idx[key] = i
1014
+ scene_counts = [0] * len(blender_objects)
1015
+ for y in range(h):
1016
+ for x in range(w):
1017
+ key = (pix[x, y][0], pix[x, y][1], pix[x, y][2])
1018
+ if key in color_to_idx:
1019
+ scene_counts[color_to_idx[key]] += 1
1020
+ finally:
1021
+ try:
1022
+ os.remove(path)
1023
+ except Exception:
1024
+ pass
1025
+
1026
+ # Second pass: per-object "full area" with other objects hidden.
1027
+ full_counts = []
1028
+ original_hide_render = [obj.hide_render for obj in blender_objects]
1029
+ try:
1030
+ for idx, obj in enumerate(blender_objects):
1031
+ # Hide all other objects, ensure this one is visible.
1032
+ for j, other in enumerate(blender_objects):
1033
+ if j == idx:
1034
+ other.hide_render = False
1035
+ else:
1036
+ other.hide_render = True
1037
+
1038
+ fd_i, path_i = tempfile.mkstemp(suffix='.png')
1039
+ os.close(fd_i)
1040
+ try:
1041
+ colors_list = render_shadeless([obj], path_i, use_distinct_colors=True)
1042
+ img = Image.open(path_i).convert('RGB')
1043
+ w, h = img.size
1044
+ pix = img.load()
1045
+ color_to_idx = {}
1046
+ for i, (r, g, b) in enumerate(colors_list):
1047
+ key = (round(r * 255), round(g * 255), round(b * 255))
1048
+ color_to_idx[key] = i
1049
+ count = 0
1050
+ for y in range(h):
1051
+ for x in range(w):
1052
+ key = (pix[x, y][0], pix[x, y][1], pix[x, y][2])
1053
+ if key in color_to_idx:
1054
+ count += 1
1055
+ full_counts.append(count)
1056
+ finally:
1057
+ try:
1058
+ os.remove(path_i)
1059
+ except Exception:
1060
+ pass
1061
+ finally:
1062
+ # Restore previous hide_render flags.
1063
+ for obj, prev in zip(blender_objects, original_hide_render):
1064
+ obj.hide_render = prev
1065
+
1066
+ visibility = []
1067
+ for scene_c, full_c in zip(scene_counts, full_counts):
1068
+ if full_c <= 0:
1069
+ visibility.append(0.0)
1070
+ else:
1071
+ visibility.append(float(scene_c) / float(full_c))
1072
+
1073
+ return visibility, scene_counts, full_counts
1074
+
1075
+
1076
+ def check_visibility(blender_objects, min_pixels_per_object):
1077
+ """
1078
+ Legacy absolute pixel-count visibility check, kept as a fallback when
1079
+ relative per-object visibility cannot be computed.
1080
+ """
1081
+ if not INSIDE_BLENDER or not blender_objects:
1082
+ return True
1083
+ if Image is None:
1084
+ return True
1085
+ fd, path = tempfile.mkstemp(suffix='.png')
1086
+ os.close(fd)
1087
+ try:
1088
+ colors_list = render_shadeless(blender_objects, path, use_distinct_colors=True)
1089
+ img = Image.open(path).convert('RGB')
1090
+ w, h = img.size
1091
+ pix = img.load()
1092
+ color_to_idx = {}
1093
+ for i, (r, g, b) in enumerate(colors_list):
1094
+ key = (round(r * 255), round(g * 255), round(b * 255))
1095
+ color_to_idx[key] = i
1096
+ counts = [0] * len(blender_objects)
1097
+ for y in range(h):
1098
+ for x in range(w):
1099
+ key = (pix[x, y][0], pix[x, y][1], pix[x, y][2])
1100
+ if key in color_to_idx:
1101
+ counts[color_to_idx[key]] += 1
1102
+ all_visible = all(c >= min_pixels_per_object for c in counts)
1103
+ return all_visible
1104
+ finally:
1105
+ try:
1106
+ os.remove(path)
1107
+ except Exception:
1108
+ pass
1109
+
1110
+
1111
+ def render_shadeless(blender_objects, path='flat.png', use_distinct_colors=False):
1112
+ """
1113
+ Render a version of the scene with shading disabled and unique materials
1114
+ assigned to all objects. The image itself is written to path. This is used to ensure
1115
+ that all objects will be visible in the final rendered scene (when check_visibility is enabled).
1116
+ Returns a list of (r,g,b) colors in object order (for visibility counting when use_distinct_colors=True).
1117
+ """
1118
+ render_args = bpy.context.scene.render
1119
+
1120
+ old_filepath = render_args.filepath
1121
+ old_engine = render_args.engine
1122
+
1123
+ render_args.filepath = path
1124
+ render_args.engine = 'BLENDER_EEVEE_NEXT'
1125
+
1126
+ view_layer = bpy.context.scene.view_layers[0]
1127
+ old_use_pass_combined = view_layer.use_pass_combined
1128
+
1129
+ for obj_name in ['Lamp_Key', 'Lamp_Fill', 'Lamp_Back', 'Ground']:
1130
+ if obj_name in bpy.data.objects:
1131
+ obj = bpy.data.objects[obj_name]
1132
+ obj.hide_render = True
1133
+
1134
+ n = len(blender_objects)
1135
+ object_colors = [] if use_distinct_colors else set()
1136
+ old_materials = []
1137
+ for i, obj in enumerate(blender_objects):
1138
+ if len(obj.data.materials) > 0:
1139
+ old_materials.append(obj.data.materials[0])
1140
+ else:
1141
+ old_materials.append(None)
1142
+
1143
+ mat = bpy.data.materials.new(name='Material_%d' % i)
1144
+ mat.use_nodes = True
1145
+ nodes = mat.node_tree.nodes
1146
+ nodes.clear()
1147
+
1148
+ node_emission = nodes.new(type='ShaderNodeEmission')
1149
+ node_output = nodes.new(type='ShaderNodeOutputMaterial')
1150
+
1151
+ if use_distinct_colors:
1152
+ r = (i + 1) / (n + 1)
1153
+ g, b = 0.5, 0.5
1154
+ object_colors.append((r, g, b))
1155
+ else:
1156
+ while True:
1157
+ r, g, b = [random.random() for _ in range(3)]
1158
+ if (r, g, b) not in object_colors:
1159
+ break
1160
+ object_colors.add((r, g, b))
1161
+
1162
+ node_emission.inputs['Color'].default_value = (r, g, b, 1.0)
1163
+ mat.node_tree.links.new(node_emission.outputs['Emission'], node_output.inputs['Surface'])
1164
+
1165
+ if len(obj.data.materials) > 0:
1166
+ obj.data.materials[0] = mat
1167
+ else:
1168
+ obj.data.materials.append(mat)
1169
+
1170
+ bpy.ops.render.render(write_still=True)
1171
+
1172
+ for mat, obj in zip(old_materials, blender_objects):
1173
+ if mat is not None:
1174
+ obj.data.materials[0] = mat
1175
+ elif len(obj.data.materials) > 0:
1176
+ obj.data.materials.clear()
1177
+
1178
+ for obj_name in ['Lamp_Key', 'Lamp_Fill', 'Lamp_Back', 'Ground']:
1179
+ if obj_name in bpy.data.objects:
1180
+ obj = bpy.data.objects[obj_name]
1181
+ obj.hide_render = False
1182
+
1183
+ render_args.filepath = old_filepath
1184
+ render_args.engine = old_engine
1185
+
1186
+ return object_colors
1187
+
1188
+
1189
+ if __name__ == '__main__':
1190
+ if INSIDE_BLENDER:
1191
+ argv = extract_args()
1192
+ args = parser.parse_args(argv)
1193
+ main(args)
1194
+ elif '--help' in sys.argv or '-h' in sys.argv:
1195
+ parser.print_help()
1196
+ else:
1197
+ print('This script is intended to be called from blender like this:')
1198
+ print()
1199
+ print('blender --background --python render_images.py -- [args]')
1200
+ print()
1201
+ print('You can also run as a standalone python script to view all')
1202
+ print('arguments like this:')
1203
+ print()
1204
+ print('python render_images.py --help')