This commit is contained in:
beabigegg
2025-07-30 11:24:58 +08:00
parent ede5af22f8
commit 9f7040ece9
10 changed files with 1667 additions and 175 deletions

23
.gitignore vendored
View File

@@ -1,2 +1,21 @@
__pycache__/wafer_processor.cpython-313.pyc
__pycache__/wafer_processor.cpython-310.pyc
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
# Virtual Environment
venv/
.venv/
# Application-specific
/static/uploads/
/static/outputs/
# IDE / Editor
.vscode/
.idea/
# OS-specific
.DS_Store
Thumbs.db

View File

@@ -1,10 +1,12 @@
# Wafer Map Pro
Wafer Map Pro is a modern, web-based tool designed for the visualization and interactive editing of semiconductor wafer maps. Built with a Python Flask backend and a dynamic HTML5 Canvas frontend, it provides a fluid and intuitive user experience for engineers and technicians working with wafer data.
Wafer Map Pro is a modern, web-based tool designed for the visualization, interactive editing, and comparison of semiconductor wafer maps. Built with a Python Flask backend and a dynamic HTML5 Canvas frontend, it provides a fluid and intuitive user experience for engineers and technicians working with wafer data.
---
## ✨ Features
### 🗺️ Wafer Map Editor
- **Interactive Canvas Renderer**: The wafer map is rendered on an HTML5 Canvas, allowing for smooth and efficient handling of large datasets.
- **Dynamic Pan & Zoom**: Effortlessly navigate the wafer map by clicking and dragging to pan, and using the mouse wheel to zoom in on areas of interest.
- **Advanced Selection Tools**:
@@ -12,15 +14,23 @@ Wafer Map Pro is a modern, web-based tool designed for the visualization and int
- **Multi-Select**: Hold `Ctrl` while clicking to select multiple, non-contiguous dies.
- **Region/Box Select**: Hold `Shift` and drag the mouse to select a rectangular area of dies.
- **Bin Code Editing**: Instantly change the bin code (`Good`, `NG`, `Dummy`, `Ignore`) of any selected die or region.
- **Map Transformation**: Rotate the entire wafer map 90 degrees clockwise with a single click.
- **Map Transformation**:
- **Rotate**: Rotate the entire wafer map 90 degrees clockwise with a single click.
- **Inset**: Automatically change the bin code of the outer N layers of dies.
- **History Control**:
- **Undo/Redo**: Step backward and forward through your edits.
- **Reset**: Revert the map to its original state.
- **Real-Time Data Analysis**: A persistent display shows the live count of each bin code, updating automatically after every edit.
- **File Handling**:
- Upload wafer maps from simple `.txt` files.
- Download the edited wafer map, preserving its structure.
- **Modern UI/UX**:
- A sleek dark mode theme reduces eye strain.
- A professional sidebar layout keeps controls organized and the map view unobstructed.
- Smooth animations and transitions provide a polished user experience.
### 🔬 Wafer Map Comparator
- **Multi-Map Upload**: Upload two or more wafer maps for side-by-side comparison.
- **Reference Point Alignment**: Set a reference die on each map to ensure accurate alignment.
- **Visual Comparison**: The tool generates a color-coded result map highlighting matches, mismatches, and areas where data is missing or partial.
- **Detailed Inspection**: Hover over any die on the result map to see a detailed tooltip showing the bin status from each of the source maps.
- **Independent Map Rotation**: Rotate individual maps within the comparison view to align them correctly before running the comparison.
---
@@ -29,7 +39,7 @@ Wafer Map Pro is a modern, web-based tool designed for the visualization and int
- **Backend**:
- **Python 3**
- **Flask**: A lightweight web framework for serving the application and handling API requests.
- **NumPy**: Used for efficient numerical operations and array manipulations (e.g., map rotation).
- **NumPy**: Used for efficient numerical operations and array manipulations (e.g., map rotation, inset, and comparison logic).
- **Frontend**:
- **HTML5**
- **CSS3**: For modern styling, animations, and the dark theme.
@@ -75,30 +85,27 @@ Follow these instructions to get a copy of the project up and running on your lo
python app.py
```
5. **Open your web browser** and navigate to the following address:
[http://127.0.0.1:5000](http://127.0.0.1:5000)
5. **Open your web browser** and navigate to the following addresses:
- **Editor**: [http://127.0.0.1:5000](http://127.0.0.1:5000)
- **Comparator**: [http://127.0.0.1:5000/compare](http://127.0.0.1:5000/compare)
---
## 📖 How to Use
1. **Step 1: Upload File**
- Click the file input area or drag and drop a `.txt` file containing your wafer map data.
- Click "Upload & Analyze".
### Wafer Map Editor
1. **Upload File**: Drag and drop a `.txt` file containing your wafer map data, or click to select a file.
2. **Define Bin Codes**: Assign a bin type (`Good`, `NG`, `Dummy`, or `Ignore`) to each unique character found in your file.
3. **Generate Map**: Click "Generate Map" to view the wafer.
4. **Interact and Edit**: Use the controls to Pan, Zoom, Select, Edit, Rotate, and Inset the map.
5. **Download**: Click "Download .txt" to save your changes.
2. **Step 2: Define Bin Codes**
- The application will automatically detect all unique characters in your file.
- For each character, use the dropdown menu to assign its corresponding bin type (`Good`, `NG`, `Dummy`, or `Ignore`).
- Click "Generate Map".
3. **Step 3: Interact and Edit**
- Your wafer map will be displayed in the main view.
- Use the controls to **Pan**, **Zoom**, **Select**, **Edit**, and **Rotate** the map.
- The bin code counts will update in real-time.
- The hint `Pro-tip: Hold Shift and drag to select a region.` is provided for quick reference.
4. **Step 4: Download**
- Once you have finished editing, click the "Download .txt" button to save your work. A new text file with the updated map will be downloaded.
### Wafer Map Comparator
1. **Navigate**: Go to the "Compare" page.
2. **Upload Files**: Select two or more `.txt` wafer map files.
3. **Define Bin Codes**: Assign bin types for all unique characters across all files.
4. **Set Reference Points**: For each map, click on a die to set it as the alignment reference. You can rotate individual maps if needed.
5. **Run Comparison**: Click "Run Comparison" to see the result map. Hover over dies for detailed information.
---
@@ -115,4 +122,4 @@ The application expects a simple text file where each character represents a die
11222211
112211
1111
```
```

88
USER_MANUAL.md Normal file
View File

@@ -0,0 +1,88 @@
# Wafer Map Pro - User Manual
Welcome to the Wafer Map Pro User Manual. This guide provides detailed instructions on how to use both the **Wafer Map Editor** and the **Wafer Map Comparator**.
---
## Part 1: Wafer Map Editor
The Editor allows you to visualize, edit, and analyze a single wafer map file.
### 1.1 - Getting Started
1. **Navigate to the Editor**: Open your web browser and go to the root URL (e.g., `http://127.0.0.1:5000`).
2. **Upload a File**:
- Drag and drop your `.txt` wafer map file onto the designated area.
- Alternatively, click the area to open a file selection dialog.
3. **Define Bin Codes**:
- After uploading, the application automatically detects all unique characters in your file.
- For each character, use the dropdown menu to assign its bin type. The available types are:
- **Good**: Represents a functional die.
- **NG** (No Good): Represents a failed die.
- **Dummy**: Represents a non-functional die used for structural purposes.
- **Ignore**: Represents areas outside the wafer boundary. These will not be rendered on the map.
4. **Generate Map**: Once all characters are defined, click the **"Generate Map"** button. Your wafer map will be rendered on the canvas.
### 1.2 - Interacting with the Map
- **Pan**: Click and hold the left mouse button anywhere on the map and drag to move it around.
- **Zoom**: Use the mouse wheel to zoom in and out. The zoom is centered on your mouse cursor's position.
### 1.3 - Editing Tools
#### Selection
- **Single Select**: Click on any single die to select it. It will be highlighted.
- **Multi-Select**: Hold down the `Ctrl` key and click on multiple dies to select them individually.
- **Region Select (Box Select)**: Hold down the `Shift` key, then click and drag the mouse to draw a rectangle. All dies within the rectangle will be selected.
#### Modifying Bins
- Once you have one or more dies selected, use the **"Edit Bin Code"** buttons (`Good`, `NG`, `Dummy`) on the sidebar to change the bin code of all selected dies.
### 1.4 - Transformation Tools
- **Rotate**: Click the **"Rotate 90°"** button to rotate the entire wafer map 90 degrees clockwise.
- **Inset**:
1. Click the **"Inset"** button.
2. A dialog will appear. Enter the number of **layers** you want to inset from the edge.
3. Select the **"From Bin"** (the bin type you want to change) and the **"To Bin"** (the bin type you want to change to).
4. Click **"Apply Inset"**. The tool will identify the outer N layers of the specified "From Bin" and change them to the "To Bin".
### 1.5 - History and Data
- **Undo/Redo**: Use the **"Undo"** and **"Redo"** buttons to step through your changes.
- **Reset**: Click **"Reset"** to revert the map to its original, freshly-loaded state.
- **Bin Code Counts**: The sidebar displays a real-time count of each bin type on the map. This updates automatically after every edit.
- **Download**: When you are finished, click the **"Download .txt"** button to save the modified wafer map to a new `.txt` file.
---
## Part 2: Wafer Map Comparator
The Comparator tool allows you to align and compare multiple wafer maps to find differences.
### 2.1 - Getting Started
1. **Navigate to the Comparator**: Open your web browser and go to the `/compare` URL (e.g., `http://127.0.0.1:5000/compare`).
2. **Upload Files**: Click the upload area and select two or more `.txt` wafer map files for comparison.
3. **Define Bin Codes**: Just like in the editor, assign a bin type for each unique character found across all uploaded files.
4. **Generate Maps**: Click **"Generate Maps"**. The individual maps will be displayed side-by-side.
### 2.2 - Alignment and Comparison
1. **Set Reference Points**: For each map, you must set a reference point for alignment. Click on a die that should correspond to the same location on the other maps. The selected reference die will be marked.
- If a map is oriented differently, use the **"Rotate"** button beneath it to align it correctly *before* setting the reference point.
- To change a reference point, simply click a different die. To clear it, use the **"Clear Reference"** button.
2. **Run Comparison**: Once a reference point is set for all maps, click the **"Run Comparison"** button.
### 2.3 - Interpreting the Results
A new, combined map will be generated with the following color code:
- **Green (Match - Good)**: All maps have a "Good" die at this location.
- **Blue (Match - NG)**: All maps have an "NG" die at this location.
- **Gray (Match - Dummy)**: All maps have a "Dummy" die at this location.
- **Red (Mismatch)**: The maps have different bin codes (e.g., one is Good, another is NG) at this location.
- **Yellow (Partial Data)**: One or more maps have data at this location, but at least one map does not (it's outside its boundary).
- **Dark Gray (No Data)**: This location is outside the boundaries of all aligned maps.
**Detailed View**: Hover your mouse over any die on the result map to see a tooltip. This tooltip shows the filename and the specific bin status for each map at that exact location, making it easy to identify the source of any mismatches or partial data.

344
app.py
View File

@@ -35,17 +35,66 @@ def index():
resp.set_cookie('session_id', session_id)
return resp
@app.route('/compare')
def compare_index():
"""Serves the comparison HTML page."""
session_id = get_session_id()
resp = make_response(render_template('compare.html'))
resp.set_cookie('session_id', session_id)
# Clean up previous comparison data if any
if session_id in session_data and 'maps' in session_data[session_id]:
del session_data[session_id]['maps']
return resp
@app.route('/upload', methods=['POST'])
def upload_file():
"""Handles file upload, saves it, and returns unique characters."""
if 'file' not in request.files:
return jsonify({"error": "No file part"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"error": "No selected file"}), 400
"""
Handles both single file upload for the editor and multiple file uploads for comparison.
It distinguishes between them by checking the 'name' attribute of the file input.
"""
session_id = get_session_id()
# Case 1: Multiple files from comparison page (input name="files")
if 'files' in request.files:
files = request.files.getlist('files')
if not files or files[0].filename == '':
return jsonify({"error": "No files selected for comparison."}), 400
session_data[session_id] = {'maps': {}}
all_unique_chars = set()
for i, file in enumerate(files):
fid = f"map_{i}"
filename = f"{session_id}_{fid}_{file.filename}"
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
unique_chars = wp.get_unique_chars(filepath)
if not unique_chars:
continue
all_unique_chars.update(unique_chars)
session_data[session_id]['maps'][fid] = {
'filepath': filepath,
'filename': file.filename,
'wafer_map': None,
'reference': None
}
if not session_data[session_id]['maps']:
return jsonify({"error": "None of the files could be processed."}), 400
return jsonify({
"unique_chars": sorted(list(all_unique_chars)),
"maps": {fid: data['filename'] for fid, data in session_data[session_id]['maps'].items()}
})
# Case 2: Single file from main editor page (input name="file")
elif 'file' in request.files:
file = request.files['file']
if file.filename == '':
return jsonify({"error": "No selected file"}), 400
if file:
session_id = get_session_id()
filename = f"{session_id}_{file.filename}"
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
@@ -57,37 +106,176 @@ def upload_file():
session_data[session_id] = {'filepath': filepath}
return jsonify({"unique_chars": unique_chars})
return jsonify({"error": "File upload failed"}), 500
# Case 3: No valid file part found
return jsonify({"error": "No file part in the request."}), 400
@app.route('/generate_map', methods=['POST'])
def generate_map():
"""Generates the initial wafer map data from the file and bin definitions."""
"""
Generates wafer map data. For comparison mode, it generates for all uploaded maps.
For editor mode, it also initializes the undo/redo history.
"""
session_id = get_session_id()
if session_id not in session_data:
return jsonify({"error": "Session not found. Please upload a file first."}), 400
return jsonify({"error": "Session not found."}), 400
data = request.json
char_map = data.get('char_map')
bin_type_to_value = {'good': 2, 'ng': 1, 'dummy': 0, 'ignore': -1}
char_to_bin_mapping = {char: bin_type_to_value[bin_type] for char, bin_type in char_map.items()}
# Comparison mode logic
if 'maps' in session_data[session_id]:
maps_data = {}
for fid, map_info in session_data[session_id]['maps'].items():
wafer_map = wp.read_wafer_map(map_info['filepath'], char_to_bin_mapping)
if wafer_map.size == 0:
continue
session_data[session_id]['maps'][fid]['wafer_map'] = wafer_map.tolist()
maps_data[fid] = {
"wafer_map": wafer_map.tolist(),
"rows": wafer_map.shape[0],
"cols": wafer_map.shape[1]
}
session_data[session_id]['char_to_bin_mapping'] = char_to_bin_mapping
return jsonify({"maps_data": maps_data})
# Editor mode logic
filepath = session_data[session_id]['filepath']
wafer_map = wp.read_wafer_map(filepath, char_to_bin_mapping)
if wafer_map.size == 0:
return jsonify({"error": "Failed to read wafer map from file."}), 500
return jsonify({"error": "Failed to read wafer map."}), 500
# Store data in session
session_data[session_id]['wafer_map'] = wafer_map.tolist()
wafer_map_list = wafer_map.tolist()
session_data[session_id]['wafer_map'] = wafer_map_list
session_data[session_id]['char_to_bin_mapping'] = char_to_bin_mapping
# Initialize history for undo/redo
session_data[session_id]['history'] = [wafer_map_list]
session_data[session_id]['history_index'] = 0
return jsonify({
"wafer_map": wafer_map.tolist(),
"wafer_map": wafer_map_list,
"rows": wafer_map.shape[0],
"cols": wafer_map.shape[1]
})
def add_to_history(session_id, new_map_state):
"""Adds a new state to the history stack for undo/redo."""
history = session_data[session_id].get('history', [])
index = session_data[session_id].get('history_index', -1)
# If we undo and then make a new change, truncate the future history
if index < len(history) - 1:
history = history[:index + 1]
history.append(new_map_state)
session_data[session_id]['history'] = history
session_data[session_id]['history_index'] = len(history) - 1
@app.route('/set_reference', methods=['POST'])
def set_reference():
"""Sets the reference point for a specific map in comparison mode."""
session_id = get_session_id()
if session_id not in session_data or 'maps' not in session_data[session_id]:
return jsonify({"error": "Not in comparison mode or session expired."}), 400
data = request.json
fid = data.get('fid')
reference = data.get('reference') # Expected: [row, col]
if fid not in session_data[session_id]['maps']:
return jsonify({"error": "Invalid map ID."}), 400
session_data[session_id]['maps'][fid]['reference'] = reference
return jsonify({"success": True, "fid": fid, "reference": reference})
@app.route('/run_comparison', methods=['POST'])
def run_comparison():
"""Runs the comparison algorithm on all maps with set references."""
session_id = get_session_id()
if session_id not in session_data or 'maps' not in session_data[session_id]:
return jsonify({"error": "Not in comparison mode or session expired."}), 400
maps_to_compare = []
references = []
for fid, map_info in session_data[session_id]['maps'].items():
if map_info.get('wafer_map') is not None and map_info.get('reference') is not None:
maps_to_compare.append(np.array(map_info['wafer_map']))
references.append(map_info['reference'])
else:
return jsonify({"error": f"Map '{map_info['filename']}' is missing data or a reference point."}), 400
if len(maps_to_compare) < 2:
return jsonify({"error": "At least two maps with reference points are required for comparison."}), 400
comparison_result, legend, detailed_grid = wp.compare_wafer_maps(maps_to_compare, references)
# Convert numpy types in detailed_grid to standard Python ints for JSON serialization
serializable_grid = [[[int(b) for b in cell] for cell in row] for row in detailed_grid]
# Store the detailed grid in the session for potential later use if needed
session_data[session_id]['comparison_details'] = {
'grid': serializable_grid,
'map_names': [info['filename'] for info in session_data[session_id]['maps'].values() if info.get('wafer_map') is not None and info.get('reference') is not None]
}
return jsonify({
"result_map": comparison_result.tolist(),
"legend": legend,
"detailed_grid": serializable_grid,
"map_names": session_data[session_id]['comparison_details']['map_names'],
"rows": comparison_result.shape[0],
"cols": comparison_result.shape[1]
})
@app.route('/compare/rotate', methods=['POST'])
def compare_rotate():
"""Rotates a single map within the comparison session."""
session_id = get_session_id()
if session_id not in session_data or 'maps' not in session_data[session_id]:
return jsonify({"error": "Not in comparison mode or session expired."}), 400
data = request.json
fid = data.get('fid')
if fid not in session_data[session_id]['maps']:
return jsonify({"error": "Invalid map ID."}), 400
map_info = session_data[session_id]['maps'][fid]
wafer_map = np.array(map_info['wafer_map'])
rotated_map = np.rot90(wafer_map, k=-1)
map_info['wafer_map'] = rotated_map.tolist()
# Clear reference if it was set, as rotation changes coordinates
map_info['reference'] = None
return jsonify({
"fid": fid,
"wafer_map": rotated_map.tolist(),
"rows": rotated_map.shape[0],
"cols": rotated_map.shape[1]
})
@app.route('/compare/clear_reference', methods=['POST'])
def compare_clear_reference():
"""Clears the reference point for a specific map."""
session_id = get_session_id()
if session_id not in session_data or 'maps' not in session_data[session_id]:
return jsonify({"error": "Not in comparison mode or session expired."}), 400
data = request.json
fid = data.get('fid')
if fid not in session_data[session_id]['maps']:
return jsonify({"error": "Invalid map ID."}), 400
session_data[session_id]['maps'][fid]['reference'] = None
return jsonify({"success": True, "fid": fid})
@app.route('/update_map', methods=['POST'])
def update_map():
"""Updates specified dies in the map and returns the full updated map."""
@@ -96,7 +284,7 @@ def update_map():
return jsonify({"error": "Session expired or invalid."}), 400
data = request.json
dies_to_update = data.get('dies') # Expected format: [[row, col], [row, col], ...]
dies_to_update = data.get('dies')
new_bin_type = data.get('bin_type')
if not dies_to_update:
@@ -107,14 +295,15 @@ def update_map():
wafer_map = np.array(session_data[session_id]['wafer_map'])
# Update the map based on the list of die coordinates
for row, col in dies_to_update:
if 0 <= row < wafer_map.shape[0] and 0 <= col < wafer_map.shape[1]:
wafer_map[row, col] = new_bin_value
session_data[session_id]['wafer_map'] = wafer_map.tolist()
wafer_map_list = wafer_map.tolist()
session_data[session_id]['wafer_map'] = wafer_map_list
add_to_history(session_id, wafer_map_list)
return jsonify({"wafer_map": wafer_map.tolist()})
return jsonify({"wafer_map": wafer_map_list})
@app.route('/rotate_map', methods=['POST'])
def rotate_map():
@@ -124,23 +313,124 @@ def rotate_map():
return jsonify({"error": "Session expired or invalid."}), 400
wafer_map = np.array(session_data[session_id]['wafer_map'])
# np.rot90 rotates counter-clockwise, so k=-1 rotates clockwise
rotated_map = np.rot90(wafer_map, k=-1)
rotated_map_list = rotated_map.tolist()
session_data[session_id]['wafer_map'] = rotated_map.tolist()
session_data[session_id]['wafer_map'] = rotated_map_list
add_to_history(session_id, rotated_map_list)
return jsonify({
"wafer_map": rotated_map.tolist(),
"wafer_map": rotated_map_list,
"rows": rotated_map.shape[0],
"cols": rotated_map.shape[1]
})
@app.route('/inset_map', methods=['POST'])
def inset_map():
"""Applies an inset to the wafer map."""
session_id = get_session_id()
if session_id not in session_data:
return jsonify({"error": "Session expired or invalid."}), 400
data = request.json
layers = int(data.get('layers', 1))
from_bin_name = data.get('from_bin')
to_bin_name = data.get('to_bin')
bin_type_to_value = {'good': 2, 'ng': 1, 'dummy': 0, 'ignore': -1}
from_bin_value = bin_type_to_value.get(from_bin_name)
to_bin_value = bin_type_to_value.get(to_bin_name)
if from_bin_value is None or to_bin_value is None:
return jsonify({"error": "Invalid 'from' or 'to' bin type specified."}), 400
wafer_map = np.array(session_data[session_id]['wafer_map'])
inset_map_array = wp.inset_wafer_map(wafer_map, layers, from_bin_value, to_bin_value)
inset_map_list = inset_map_array.tolist()
session_data[session_id]['wafer_map'] = inset_map_list
add_to_history(session_id, inset_map_list)
return jsonify({
"wafer_map": inset_map_list,
"rows": inset_map_array.shape[0],
"cols": inset_map_array.shape[1]
})
@app.route('/undo', methods=['POST'])
def undo():
session_id = get_session_id()
if session_id not in session_data or not session_data[session_id].get('history'):
return jsonify({"error": "No history to undo."}), 400
history = session_data[session_id]['history']
index = session_data[session_id]['history_index']
if index > 0:
index -= 1
session_data[session_id]['history_index'] = index
wafer_map = history[index]
session_data[session_id]['wafer_map'] = wafer_map
return jsonify({
"wafer_map": wafer_map,
"rows": len(wafer_map),
"cols": len(wafer_map[0]) if wafer_map else 0,
"can_undo": index > 0,
"can_redo": True
})
return jsonify({"error": "Already at the beginning of history."}), 400
@app.route('/redo', methods=['POST'])
def redo():
session_id = get_session_id()
if session_id not in session_data or not session_data[session_id].get('history'):
return jsonify({"error": "No history to redo."}), 400
history = session_data[session_id]['history']
index = session_data[session_id]['history_index']
if index < len(history) - 1:
index += 1
session_data[session_id]['history_index'] = index
wafer_map = history[index]
session_data[session_id]['wafer_map'] = wafer_map
return jsonify({
"wafer_map": wafer_map,
"rows": len(wafer_map),
"cols": len(wafer_map[0]) if wafer_map else 0,
"can_undo": True,
"can_redo": index < len(history) - 1
})
return jsonify({"error": "Already at the end of history."}), 400
@app.route('/reset_map', methods=['POST'])
def reset_map():
"""Resets the map to its original state (the first state in history)."""
session_id = get_session_id()
if session_id not in session_data or not session_data[session_id].get('history'):
return jsonify({"error": "No history available to reset from."}), 400
history = session_data[session_id]['history']
original_map = history[0]
# Reset history to only the original state
session_data[session_id]['history'] = [original_map]
session_data[session_id]['history_index'] = 0
session_data[session_id]['wafer_map'] = original_map
return jsonify({
"wafer_map": original_map,
"rows": len(original_map),
"cols": len(original_map[0]) if original_map else 0,
"can_undo": False,
"can_redo": False
})
@app.route('/download', methods=['GET'])
def download_file():
"""Saves the current wafer map to a text file and serves it for download."""
session_id = get_session_id()
if session_id not in session_data:
if session_id not in session_data or 'wafer_map' not in session_data[session_id]:
return jsonify({"error": "No data to download."}), 400
wafer_map = np.array(session_data[session_id]['wafer_map'])

View File

@@ -163,21 +163,109 @@ select {
background-color: var(--secondary-color-hover);
}
#controls {
display: flex;
flex-direction: column;
gap: 15px;
gap: 10px; /* Gap between control sections */
}
.control-section {
padding-top: 15px;
border-top: 1px solid var(--border-color);
}
.control-section:first-child {
padding-top: 0;
border-top: none;
}
.control-section-header {
font-size: 12px;
font-weight: 600;
color: var(--text-color-dark);
text-transform: uppercase;
margin-bottom: 15px;
}
.control-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px; /* row-gap column-gap */
margin-bottom: 15px;
}
.control-group:last-child {
margin-bottom: 0;
}
.control-group label {
flex-shrink: 0;
font-weight: 500;
}
.control-group select,
.control-group input[type="number"],
.control-group .button-secondary {
flex-grow: 1; /* Allow items to grow */
min-width: 80px; /* Prevent items from becoming too small */
}
.control-group .btn-group {
display: flex;
flex-grow: 1;
}
.btn-group .button-secondary {
border-radius: 0;
}
.btn-group .button-secondary:first-child {
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
.btn-group .button-secondary:last-child {
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
.btn-group .button-secondary.active {
background-color: var(--primary-color);
color: white;
}
.control-group.vertical {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.inset-control-row {
display: flex;
align-items: center;
gap: 10px;
}
.inset-control-row label {
width: 50px; /* Fixed width for alignment */
flex-shrink: 0;
}
.inset-control-row select,
.inset-control-row input {
width: 100%;
}
.control-group-divider {
height: 1px;
background-color: var(--border-color);
width: 100%;
margin: 5px 0;
}
.control-group-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.control-group-grid.four-cols {
grid-template-columns: 1fr 1fr;
}
.control-group-grid .button-primary,
.control-group-grid .button-secondary {
width: 100%;
@@ -187,10 +275,8 @@ select {
.hint-text {
font-size: 12px;
color: var(--text-color-dark);
text-align: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--border-color);
text-align: left;
padding: 0 5px;
}
.hint-text strong {
color: var(--text-color);
@@ -214,7 +300,7 @@ select {
}
/* --- Main Editor Area --- */
#editor-section {
#editor-section, #viewer-section, #comparison-result-section {
display: flex;
flex-direction: column;
height: 100%;
@@ -244,6 +330,9 @@ select {
border: 1px solid var(--border-color);
}
.count-item {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.count-item span {
@@ -255,8 +344,14 @@ select {
display: inline-block;
text-align: center;
}
#result-legend .count-item span {
width: 20px;
height: 20px;
border: 1px solid rgba(255,255,255,0.2);
}
#map-container {
#map-container, #result-map-container {
flex-grow: 1;
position: relative;
cursor: grab;
@@ -265,31 +360,107 @@ select {
border: 1px solid var(--border-color);
background-color: var(--card-bg);
}
#map-container:active {
#map-container:active, #result-map-container:active {
cursor: grabbing;
}
#wafer-map-canvas {
#wafer-map-canvas, #result-map-canvas {
display: block;
width: 100%;
height: 100%;
}
.map-viewer-controls {
display: flex;
gap: 15px;
align-items: center;
margin-bottom: 15px;
}
.map-viewer-controls label {
font-weight: 500;
}
/* --- Comparison Page Specifics --- */
.reference-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-radius: 4px;
background-color: var(--secondary-color);
gap: 10px;
}
.reference-item .filename {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
}
.reference-item .ref-details {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.ref-status {
font-weight: 500;
color: var(--text-color-dark);
}
.ref-status.set {
color: var(--primary-color);
font-weight: 600;
}
.clear-ref-btn {
background: none;
border: none;
color: var(--text-color-dark);
font-size: 16px;
font-weight: bold;
cursor: pointer;
padding: 0 5px;
display: none; /* Hidden by default */
}
.clear-ref-btn:hover {
color: var(--error-color);
}
/* --- Tooltip --- */
.map-tooltip {
position: absolute;
display: none;
background-color: rgba(0, 0, 0, 0.85);
color: white;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 8px 12px;
font-size: 12px;
pointer-events: none; /* So it doesn't interfere with mouse events */
white-space: pre; /* To respect newlines */
z-index: 100;
transition: opacity 0.1s ease;
}
.reference-item.has-ref .clear-ref-btn {
display: inline-block; /* Show only when ref is set */
}
/* --- Status & Spinner --- */
.status {
margin-top: 15px;
padding: 10px;
border-radius: 4px;
display: none;
font-weight: 500;
text-align: center;
display: none; /* Use classes to show */
}
.status.success {
background-color: var(--success-color);
background-color: rgba(40, 167, 69, 0.8);
color: white;
display: block;
}
.status.error {
background-color: var(--error-color);
background-color: rgba(220, 53, 69, 0.8);
color: white;
display: block;
}
@@ -307,4 +478,4 @@ select {
@keyframes spinner-border {
to { transform: rotate(360deg); }
}
}

430
static/js/compare.js Normal file
View File

@@ -0,0 +1,430 @@
document.addEventListener('DOMContentLoaded', () => {
// --- DOM Elements ---
const uploadForm = document.getElementById('upload-form');
const binDefinitionForm = document.getElementById('bin-definition-form');
const fileInput = document.getElementById('file-input');
const uploadSection = document.getElementById('upload-section');
const binDefinitionSection = document.getElementById('bin-definition-section');
const comparisonControlsSection = document.getElementById('comparison-controls-section');
const viewerSection = document.getElementById('viewer-section');
const comparisonResultSection = document.getElementById('comparison-result-section');
const welcomeMessage = document.getElementById('welcome-message');
const uploadStatus = document.getElementById('upload-status');
const binStatus = document.getElementById('bin-status');
const compareStatus = document.getElementById('compare-status');
const binMapContainer = document.getElementById('bin-map-container');
const referenceSetterContainer = document.getElementById('reference-setter-container');
const mapSelector = document.getElementById('map-selector');
const rotateMapBtn = document.getElementById('rotate-map-btn');
const compareBtn = document.getElementById('compare-btn');
const backToViewerBtn = document.getElementById('back-to-viewer-btn');
const canvas = document.getElementById('wafer-map-canvas');
const ctx = canvas.getContext('2d');
const resultCanvas = document.getElementById('result-map-canvas');
const resultCtx = resultCanvas.getContext('2d');
const resultLegendContainer = document.getElementById('result-legend');
const tooltip = document.getElementById('map-tooltip');
// --- State Management ---
let sessionMaps = {};
let activeMapFid = null;
let comparisonData = {}; // To store result_map, detailed_grid, etc.
// --- Canvas & View State ---
const BIN_CODE_MAP = { 2: 'Good', 1: 'NG', 0: 'Dummy', '-1': 'Ignore', '-10': 'N/A' };
const BIN_COLORS = { 2: '#28a745', 1: '#dc3545', 0: '#6c757d', '-1': '#3e3e42' };
const REFERENCE_COLOR = 'rgba(255, 193, 7, 1)';
let transform = { x: 0, y: 0, scale: 1 };
let isPanning = false;
let panStart = { x: 0, y: 0 };
let resultTransform = { x: 0, y: 0, scale: 1 };
let isResultPanning = false;
let resultPanStart = { x: 0, y: 0 };
let highlightedDie = null;
// --- UI Update Functions ---
function setStatus(element, message, isError = false) {
element.textContent = message;
element.className = 'status';
if (message) {
element.classList.add(isError ? 'error' : 'success');
}
}
function switchCard(from, to) {
if(from) from.classList.remove('active');
if(to) to.classList.add('active');
}
function updateReferenceUI() {
referenceSetterContainer.innerHTML = '';
Object.keys(sessionMaps).forEach(fid => {
const map = sessionMaps[fid];
const hasRef = map.reference !== null;
const refText = hasRef ? `(${map.reference.join(', ')})` : 'Not Set';
const item = document.createElement('div');
item.className = `reference-item ${hasRef ? 'has-ref' : ''}`;
item.id = `ref-item-${fid}`;
item.innerHTML = `
<span class="filename" title="${map.filename}">${map.filename}</span>
<div class="ref-details">
<span class="ref-status ${hasRef ? 'set' : ''}">${refText}</span>
<button class="clear-ref-btn" data-fid="${fid}" title="Clear reference">×</button>
</div>
`;
referenceSetterContainer.appendChild(item);
});
checkAllReferencesSet();
}
function checkAllReferencesSet() {
const allSet = Object.values(sessionMaps).every(m => m.reference !== null);
compareBtn.disabled = !allSet;
if (allSet) {
setStatus(compareStatus, 'All references set. Ready to compare.');
} else {
setStatus(compareStatus, 'Please set a reference point for each map.');
}
}
// --- Canvas & Drawing ---
function worldToScreen(x, y, t) { return { x: x * t.scale + t.x, y: y * t.scale + t.y }; }
function screenToWorld(x, y, t) { return { x: (x - t.x) / t.scale, y: (y - t.y) / t.scale }; }
function resetTransform(map, t, targetCanvas) {
const dieSize = 10;
const container = targetCanvas.parentElement;
targetCanvas.width = container.clientWidth;
targetCanvas.height = container.clientHeight;
const scaleX = targetCanvas.width / (map.cols * dieSize);
const scaleY = targetCanvas.height / (map.rows * dieSize);
t.scale = Math.min(scaleX, scaleY) * 0.9;
const mapRenderWidth = map.cols * dieSize * t.scale;
const mapRenderHeight = map.rows * dieSize * t.scale;
t.x = (targetCanvas.width - mapRenderWidth) / 2;
t.y = (targetCanvas.height - mapRenderHeight) / 2;
}
function drawActiveMap() {
if (!activeMapFid || !sessionMaps[activeMapFid] || !sessionMaps[activeMapFid].wafer_map) return;
const map = sessionMaps[activeMapFid];
requestAnimationFrame(() => {
const dieSize = 10;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(transform.x, transform.y);
ctx.scale(transform.scale, transform.scale);
for (let r = 0; r < map.rows; r++) {
for (let c = 0; c < map.cols; c++) {
const bin = map.wafer_map[r][c];
ctx.fillStyle = BIN_COLORS[bin] || '#ffffff';
ctx.fillRect(c * dieSize, r * dieSize, dieSize, dieSize);
}
}
if (map.reference) {
const [r, c] = map.reference;
ctx.strokeStyle = REFERENCE_COLOR;
ctx.lineWidth = 2 / transform.scale;
ctx.strokeRect(c * dieSize - 1, r * dieSize - 1, dieSize + 2, dieSize + 2);
}
ctx.restore();
});
}
function drawResultMap() {
const { rows, cols, result_map, legend } = comparisonData;
requestAnimationFrame(() => {
const dieSize = 10;
resultCtx.clearRect(0, 0, resultCanvas.width, resultCanvas.height);
resultCtx.save();
resultCtx.translate(resultTransform.x, resultTransform.y);
resultCtx.scale(resultTransform.scale, resultTransform.scale);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const code = result_map[r][c];
resultCtx.fillStyle = legend[code] ? legend[code].color : '#ffffff';
resultCtx.fillRect(c * dieSize, r * dieSize, dieSize, dieSize);
}
}
// Draw highlight on hovered die
if (highlightedDie) {
const { r, c } = highlightedDie;
resultCtx.strokeStyle = REFERENCE_COLOR;
resultCtx.lineWidth = 2 / resultTransform.scale;
resultCtx.strokeRect(c * dieSize, r * dieSize, dieSize, dieSize);
}
resultCtx.restore();
});
}
// --- Event Handlers ---
uploadForm.addEventListener('submit', async (e) => {
e.preventDefault();
setStatus(uploadStatus, '');
if (fileInput.files.length < 2) {
setStatus(uploadStatus, 'Please select at least two files for comparison.', true);
return;
}
const formData = new FormData(uploadForm);
try {
const response = await fetch('/upload', { method: 'POST', body: formData });
const result = await response.json();
if (!response.ok) throw new Error(result.error);
binMapContainer.innerHTML = '';
result.unique_chars.forEach(char => {
const item = document.createElement('div');
item.className = 'bin-item';
item.innerHTML = `<label>'${char}':</label><select name="${char}"><option value="good">Good</option><option value="ng">NG</option><option value="dummy">Dummy</option><option value="ignore">Ignore</option></select>`;
binMapContainer.appendChild(item);
});
mapSelector.innerHTML = '';
sessionMaps = {}; // Clear previous session
Object.keys(result.maps).forEach(fid => {
sessionMaps[fid] = { filename: result.maps[fid], wafer_map: null, reference: null };
const option = document.createElement('option');
option.value = fid;
option.textContent = result.maps[fid];
mapSelector.appendChild(option);
});
activeMapFid = mapSelector.value;
switchCard(uploadSection, binDefinitionSection);
} catch (error) {
setStatus(uploadStatus, `Error: ${error.message}`, true);
}
});
binDefinitionForm.addEventListener('submit', async (e) => {
e.preventDefault();
setStatus(binStatus, '');
const formData = new FormData(binDefinitionForm);
const charMap = Object.fromEntries(formData.entries());
try {
const response = await fetch('/generate_map', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ char_map: charMap }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
Object.keys(result.maps_data).forEach(fid => {
sessionMaps[fid] = { ...sessionMaps[fid], ...result.maps_data[fid] };
});
welcomeMessage.style.display = 'none';
viewerSection.style.display = 'flex';
resetTransform(sessionMaps[activeMapFid], transform, canvas);
drawActiveMap();
updateReferenceUI();
switchCard(binDefinitionSection, comparisonControlsSection);
} catch (error) {
setStatus(binStatus, `Error: ${error.message}`, true);
}
});
mapSelector.addEventListener('change', (e) => {
activeMapFid = e.target.value;
resetTransform(sessionMaps[activeMapFid], transform, canvas);
drawActiveMap();
});
rotateMapBtn.addEventListener('click', async () => {
if (!activeMapFid) return;
try {
const response = await fetch('/compare/rotate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fid: activeMapFid }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
sessionMaps[activeMapFid].wafer_map = result.wafer_map;
sessionMaps[activeMapFid].rows = result.rows;
sessionMaps[activeMapFid].cols = result.cols;
sessionMaps[activeMapFid].reference = null;
resetTransform(sessionMaps[activeMapFid], transform, canvas);
drawActiveMap();
updateReferenceUI();
} catch (error) {
setStatus(compareStatus, `Error rotating map: ${error.message}`, true);
}
});
referenceSetterContainer.addEventListener('click', async (e) => {
if (e.target.classList.contains('clear-ref-btn')) {
const fid = e.target.dataset.fid;
try {
const response = await fetch('/compare/clear_reference', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fid: fid }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
sessionMaps[fid].reference = null;
updateReferenceUI();
if (fid === activeMapFid) drawActiveMap();
} catch (error) {
setStatus(compareStatus, `Error clearing reference: ${error.message}`, true);
}
}
});
canvas.addEventListener('click', async (e) => {
const wasDrag = Math.abs(e.clientX - panStart.x) > 2 || Math.abs(e.clientY - panStart.y) > 2;
if ((isPanning && wasDrag) || !activeMapFid) return;
const worldPos = screenToWorld(e.offsetX, e.offsetY, transform);
const col = Math.floor(worldPos.x / 10);
const row = Math.floor(worldPos.y / 10);
const map = sessionMaps[activeMapFid];
if (row >= 0 && row < map.rows && col >= 0 && col < map.cols) {
const reference = [row, col];
try {
const response = await fetch('/set_reference', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fid: activeMapFid, reference: reference }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
sessionMaps[activeMapFid].reference = result.reference;
updateReferenceUI();
drawActiveMap();
} catch (error) {
setStatus(compareStatus, `Error: ${error.message}`, true);
}
}
});
compareBtn.addEventListener('click', async () => {
setStatus(compareStatus, 'Running comparison...');
try {
const response = await fetch('/run_comparison', { method: 'POST' });
const result = await response.json();
if (!response.ok) throw new Error(result.error);
comparisonData = result;
viewerSection.style.display = 'none';
comparisonResultSection.style.display = 'flex';
resetTransform(comparisonData, resultTransform, resultCanvas);
drawResultMap();
resultLegendContainer.innerHTML = '<h3>Legend</h3>';
for (const code in result.legend) {
const item = result.legend[code];
const legendItem = document.createElement('div');
legendItem.className = 'count-item';
legendItem.innerHTML = `${item.label}: <span style="background-color:${item.color}"></span>`;
resultLegendContainer.appendChild(legendItem);
}
} catch (error) {
setStatus(compareStatus, `Error: ${error.message}`, true);
}
});
backToViewerBtn.addEventListener('click', () => {
comparisonResultSection.style.display = 'none';
viewerSection.style.display = 'flex';
});
// --- Canvas Pan & Zoom ---
function setupCanvasEvents(targetCanvas, t, panFlagSetter, drawFn) {
targetCanvas.addEventListener('wheel', (e) => { e.preventDefault(); const rect = targetCanvas.getBoundingClientRect(); const mouse = { x: e.clientX - rect.left, y: e.clientY - rect.top }; const worldPos = screenToWorld(mouse.x, mouse.y, t); const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1; t.scale *= zoomFactor; const newWorldPos = screenToWorld(mouse.x, mouse.y, t); t.x += (newWorldPos.x - worldPos.x) * t.scale; t.y += (newWorldPos.y - worldPos.y) * t.scale; drawFn(); });
targetCanvas.addEventListener('mousedown', (e) => { panFlagSetter(true, e); });
targetCanvas.addEventListener('mousemove', (e) => { panFlagSetter(false, e, true, drawFn); });
}
setupCanvasEvents(canvas, transform, (val, e) => { isPanning = val; if(val) panStart = { x: e.clientX, y: e.clientY }; }, drawActiveMap);
setupCanvasEvents(resultCanvas, resultTransform, (val, e) => { isResultPanning = val; if(val) resultPanStart = { x: e.clientX, y: e.clientY }; }, drawResultMap);
window.addEventListener('mouseup', () => { isPanning = false; isResultPanning = false; });
window.addEventListener('mousemove', (e) => {
if (isPanning) { const dx = e.clientX - panStart.x; const dy = e.clientY - panStart.y; transform.x += dx; transform.y += dy; panStart = { x: e.clientX, y: e.clientY }; drawActiveMap(); }
if (isResultPanning) { const dx = e.clientX - resultPanStart.x; const dy = e.clientY - resultPanStart.y; resultTransform.x += dx; resultTransform.y += dy; resultPanStart = { x: e.clientX, y: e.clientY }; drawResultMap(); }
});
// --- Tooltip Logic ---
resultCanvas.addEventListener('mousemove', (e) => {
if (!comparisonData.detailed_grid || isResultPanning) return;
const rect = resultCanvas.getBoundingClientRect();
const mouse = { x: e.clientX - rect.left, y: e.clientY - rect.top };
const worldPos = screenToWorld(mouse.x, mouse.y, resultTransform);
const c = Math.floor(worldPos.x / 10);
const r = Math.floor(worldPos.y / 10);
if (r >= 0 && r < comparisonData.rows && c >= 0 && c < comparisonData.cols) {
highlightedDie = { r, c };
const dieData = comparisonData.detailed_grid[r][c];
let tooltipContent = `Coord: (${r}, ${c})\n-------------------\n`;
tooltipContent += comparisonData.map_names.map((name, i) => {
const binCode = dieData[i];
return `${name}: ${BIN_CODE_MAP[binCode] || 'Unknown'}`;
}).join('\n');
tooltip.style.display = 'block';
tooltip.style.opacity = '1';
tooltip.textContent = tooltipContent;
// Position tooltip
const tooltipRect = tooltip.getBoundingClientRect();
let left = e.clientX + 15;
let top = e.clientY + 15;
if (left + tooltipRect.width > window.innerWidth) {
left = e.clientX - tooltipRect.width - 15;
}
if (top + tooltipRect.height > window.innerHeight) {
top = e.clientY - tooltipRect.height - 15;
}
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
} else {
highlightedDie = null;
tooltip.style.display = 'none';
tooltip.style.opacity = '0';
}
drawResultMap();
});
resultCanvas.addEventListener('mouseleave', () => {
highlightedDie = null;
tooltip.style.display = 'none';
tooltip.style.opacity = '0';
drawResultMap();
});
new ResizeObserver(() => { if (activeMapFid) { resetTransform(sessionMaps[activeMapFid], transform, canvas); drawActiveMap(); } }).observe(canvas.parentElement);
new ResizeObserver(() => { if (comparisonData.rows) { resetTransform(comparisonData, resultTransform, resultCanvas); drawResultMap(); } }).observe(resultCanvas.parentElement);
});

View File

@@ -20,6 +20,11 @@ document.addEventListener('DOMContentLoaded', () => {
const updateSelectionBtn = document.getElementById('update-selection-btn');
const resetViewBtn = document.getElementById('reset-view-btn');
const rotateBtn = document.getElementById('rotate-btn');
const polygonSelectBtn = document.getElementById('polygon-select-btn');
const boxSelectBtn = document.getElementById('box-select-btn');
const insetMapBtn = document.getElementById('inset-map-btn');
const undoBtn = document.getElementById('undo-btn');
const redoBtn = document.getElementById('redo-btn');
const binCountsContainer = document.getElementById('bin-counts');
// --- State Management ---
@@ -33,10 +38,17 @@ document.addEventListener('DOMContentLoaded', () => {
const SELECTION_COLOR = 'rgba(0, 123, 255, 0.5)';
let transform = { x: 0, y: 0, scale: 1 };
let isPanning = false;
let isSelecting = false;
let panStart = { x: 0, y: 0 };
let selectionStart = { x: 0, y: 0 };
let selectionEnd = { x: 0, y: 0 };
// --- Selection modes ---
let selectionMode = 'box'; // 'box', 'polygon'
let isBoxSelecting = false;
let boxSelectionStart = { x: 0, y: 0 };
let boxSelectionEnd = { x: 0, y: 0 };
let isDrawingPolygon = false;
let polygonPoints = [];
let currentMousePos = { x: 0, y: 0 };
// --- UI/UX Functions ---
function setStatus(element, message, isError = false) {
@@ -62,6 +74,21 @@ document.addEventListener('DOMContentLoaded', () => {
if(to) to.classList.add('active');
}
function updateHistoryButtons(canUndo, canRedo) {
undoBtn.disabled = !canUndo;
redoBtn.disabled = !canRedo;
}
function handleMapUpdate(result) {
waferMap = result.wafer_map;
mapRows = result.rows;
mapCols = result.cols;
selection.clear();
updateBinCounts();
draw();
}
// --- Canvas & Data Functions ---
function worldToScreen(x, y) {
return { x: x * transform.scale + transform.x, y: y * transform.scale + transform.y };
@@ -121,17 +148,7 @@ document.addEventListener('DOMContentLoaded', () => {
function draw() {
requestAnimationFrame(() => {
const dieSize = 10;
const minScaleForGrid = 4;
const maxScaleForGrid = 10;
let gridAlpha = (transform.scale - minScaleForGrid) / (maxScaleForGrid - minScaleForGrid);
gridAlpha = Math.min(1, Math.max(0, gridAlpha));
// Dynamically set canvas background
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (gridAlpha > 0) {
ctx.fillStyle = `rgba(240, 240, 240, ${gridAlpha})`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.save();
ctx.translate(transform.x, transform.y);
@@ -145,24 +162,6 @@ document.addEventListener('DOMContentLoaded', () => {
ctx.fillRect(c * dieSize, r * dieSize, dieSize, dieSize);
}
}
// Draw grid lines
if (gridAlpha > 0) {
ctx.strokeStyle = `rgba(0, 0, 0, ${gridAlpha})`;
ctx.lineWidth = 1 / transform.scale;
for (let r = 0; r <= mapRows; r++) {
ctx.beginPath();
ctx.moveTo(0, r * dieSize);
ctx.lineTo(mapCols * dieSize, r * dieSize);
ctx.stroke();
}
for (let c = 0; c <= mapCols; c++) {
ctx.beginPath();
ctx.moveTo(c * dieSize, 0);
ctx.lineTo(c * dieSize, mapRows * dieSize);
ctx.stroke();
}
}
// Draw selection highlights
ctx.fillStyle = SELECTION_COLOR;
@@ -174,19 +173,44 @@ document.addEventListener('DOMContentLoaded', () => {
ctx.restore();
// Draw selection rectangle (in screen space)
if (isSelecting) {
if (isBoxSelecting) {
ctx.fillStyle = 'rgba(0, 123, 255, 0.2)';
ctx.strokeStyle = 'rgba(0, 123, 255, 0.8)';
ctx.lineWidth = 1;
const rect = {
x: Math.min(selectionStart.x, selectionEnd.x),
y: Math.min(selectionStart.y, selectionEnd.y),
w: Math.abs(selectionStart.x - selectionEnd.x),
h: Math.abs(selectionStart.y - selectionEnd.y)
x: Math.min(boxSelectionStart.x, boxSelectionEnd.x),
y: Math.min(boxSelectionStart.y, boxSelectionEnd.y),
w: Math.abs(boxSelectionStart.x - boxSelectionEnd.x),
h: Math.abs(boxSelectionStart.y - boxSelectionEnd.y)
};
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
}
// Draw polygon (in screen space)
if (isDrawingPolygon && polygonPoints.length > 0) {
ctx.beginPath();
ctx.moveTo(polygonPoints[0].x, polygonPoints[0].y);
for (let i = 1; i < polygonPoints.length; i++) {
ctx.lineTo(polygonPoints[i].x, polygonPoints[i].y);
}
ctx.strokeStyle = 'rgba(255, 0, 0, 0.8)';
ctx.lineWidth = 2;
ctx.stroke();
// Draw line to current mouse position
ctx.lineTo(currentMousePos.x, currentMousePos.y);
ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
ctx.stroke();
// Draw vertices
ctx.fillStyle = 'red';
polygonPoints.forEach(p => {
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, 2 * Math.PI);
ctx.fill();
});
}
});
}
@@ -241,16 +265,13 @@ document.addEventListener('DOMContentLoaded', () => {
const result = await response.json();
if (!response.ok) throw new Error(result.error);
waferMap = result.wafer_map;
mapRows = result.rows;
mapCols = result.cols;
welcomeMessage.style.display = 'none';
editorSection.style.display = 'flex';
handleMapUpdate(result);
resetTransform();
updateBinCounts();
draw();
updateHistoryButtons(false, false); // History starts here
switchCard(binDefinitionSection, editorControlsSection);
} catch (error) {
@@ -260,6 +281,7 @@ document.addEventListener('DOMContentLoaded', () => {
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
if (isDrawingPolygon) return;
const rect = canvas.getBoundingClientRect();
const mouse = { x: e.clientX - rect.left, y: e.clientY - rect.top };
const worldPos = screenToWorld(mouse.x, mouse.y);
@@ -275,10 +297,12 @@ document.addEventListener('DOMContentLoaded', () => {
});
canvas.addEventListener('mousedown', (e) => {
if (e.shiftKey) {
isSelecting = true;
selectionStart = { x: e.offsetX, y: e.offsetY };
selectionEnd = selectionStart;
if (selectionMode === 'box' && e.shiftKey) {
isBoxSelecting = true;
boxSelectionStart = { x: e.offsetX, y: e.offsetY };
boxSelectionEnd = boxSelectionStart;
} else if (selectionMode === 'polygon') {
// Polygon drawing is handled in 'click' to avoid conflict with panning
} else {
isPanning = true;
panStart = { x: e.clientX, y: e.clientY };
@@ -286,6 +310,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
canvas.addEventListener('mousemove', (e) => {
currentMousePos = { x: e.offsetX, y: e.offsetY };
if (isPanning) {
const dx = e.clientX - panStart.x;
const dy = e.clientY - panStart.y;
@@ -293,55 +318,157 @@ document.addEventListener('DOMContentLoaded', () => {
transform.y += dy;
panStart = { x: e.clientX, y: e.clientY };
draw();
} else if (isSelecting) {
selectionEnd = { x: e.offsetX, y: e.offsetY };
} else if (isBoxSelecting) {
boxSelectionEnd = { x: e.offsetX, y: e.offsetY };
draw();
} else if (isDrawingPolygon) {
draw();
}
});
window.addEventListener('mouseup', (e) => {
if (isPanning) isPanning = false;
if (isSelecting) {
isSelecting = false;
const startWorld = screenToWorld(selectionStart.x, selectionStart.y);
const endWorld = screenToWorld(selectionEnd.x, selectionEnd.y);
const startCol = Math.floor(Math.min(startWorld.x, endWorld.x) / 10);
const endCol = Math.floor(Math.max(startWorld.x, endWorld.x) / 10);
const startRow = Math.floor(Math.min(startWorld.y, endWorld.y) / 10);
const endRow = Math.floor(Math.max(startWorld.y, endWorld.y) / 10);
if (!e.ctrlKey) selection.clear();
for (let r = startRow; r <= endRow; r++) {
for (let c = startCol; c <= endCol; c++) {
if (r >= 0 && r < mapRows && c >= 0 && c < mapCols) {
const key = `${r},${c}`;
if (selection.has(key)) selection.delete(key);
else selection.add(key);
}
}
}
setStatus(editorStatus, `${selection.size} die(s) selected.`);
if (isBoxSelecting) {
isBoxSelecting = false;
selectDiesInBox();
draw();
}
});
canvas.addEventListener('click', (e) => {
const wasDrag = Math.abs(e.clientX - panStart.x) > 2 || Math.abs(e.clientY - panStart.y) > 2;
if (e.shiftKey || (isPanning && wasDrag)) return;
if (isPanning && wasDrag) return;
const die = getDieAtScreen(e.offsetX, e.offsetY);
if (die) {
if (!e.ctrlKey) selection.clear();
const key = `${die.row},${die.col}`;
if (selection.has(key)) selection.delete(key);
else selection.add(key);
setStatus(editorStatus, `${selection.size} die(s) selected.`);
draw();
if (selectionMode === 'polygon') {
handlePolygonClick(e);
} else {
if (e.shiftKey) return; // Box selection is handled in mousedown/up
const die = getDieAtScreen(e.offsetX, e.offsetY);
if (die) {
if (!e.ctrlKey) selection.clear();
const key = `${die.row},${die.col}`;
if (selection.has(key)) selection.delete(key);
else selection.add(key);
setStatus(editorStatus, `${selection.size} die(s) selected.`);
draw();
}
}
});
canvas.addEventListener('dblclick', (e) => {
if (selectionMode === 'polygon' && isDrawingPolygon) {
e.preventDefault();
closeAndSelectPolygon();
}
});
// --- Selection Logic ---
function selectDiesInBox() {
const startWorld = screenToWorld(boxSelectionStart.x, boxSelectionStart.y);
const endWorld = screenToWorld(boxSelectionEnd.x, boxSelectionEnd.y);
const startCol = Math.floor(Math.min(startWorld.x, endWorld.x) / 10);
const endCol = Math.floor(Math.max(startWorld.x, endWorld.x) / 10);
const startRow = Math.floor(Math.min(startWorld.y, endWorld.y) / 10);
const endRow = Math.floor(Math.max(startWorld.y, endWorld.y) / 10);
if (!window.event.ctrlKey) selection.clear();
for (let r = startRow; r <= endRow; r++) {
for (let c = startCol; c <= endCol; c++) {
if (r >= 0 && r < mapRows && c >= 0 && c < mapCols) {
const key = `${r},${c}`;
if (selection.has(key)) selection.delete(key);
else selection.add(key);
}
}
}
setStatus(editorStatus, `${selection.size} die(s) selected.`);
}
function handlePolygonClick(e) {
const point = { x: e.offsetX, y: e.offsetY };
if (!isDrawingPolygon) {
isDrawingPolygon = true;
polygonPoints = [point];
setStatus(editorStatus, 'Polygon drawing started. Click to add points, double-click to finish.');
} else {
// Check if closing polygon
const firstPoint = polygonPoints[0];
const dist = Math.sqrt(Math.pow(point.x - firstPoint.x, 2) + Math.pow(point.y - firstPoint.y, 2));
if (dist < 10 && polygonPoints.length > 2) {
closeAndSelectPolygon();
} else {
polygonPoints.push(point);
}
}
draw();
}
function closeAndSelectPolygon() {
if (!window.event.ctrlKey) selection.clear();
selectDiesInPolygon();
isDrawingPolygon = false;
polygonPoints = [];
draw();
}
function selectDiesInPolygon() {
const dieSize = 10;
const polygonInWorld = polygonPoints.map(p => screenToWorld(p.x, p.y));
for (let r = 0; r < mapRows; r++) {
for (let c = 0; c < mapCols; c++) {
const dieCenter = {
x: c * dieSize + dieSize / 2,
y: r * dieSize + dieSize / 2
};
if (pointInPolygon(dieCenter, polygonInWorld)) {
const key = `${r},${c}`;
if (selection.has(key)) selection.delete(key);
else selection.add(key);
}
}
}
setStatus(editorStatus, `${selection.size} die(s) selected.`);
}
function pointInPolygon(point, vs) {
// ray-casting algorithm based on
// https://wrf.ecse.rpi.edu/Research/Short_Notes/pnpoly.html
const x = point.x, y = point.y;
let inside = false;
for (let i = 0, j = vs.length - 1; i < vs.length; j = i++) {
const xi = vs[i].x, yi = vs[i].y;
const xj = vs[j].x, yj = vs[j].y;
const intersect = ((yi > y) !== (yj > y))
&& (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
}
// --- Action Buttons ---
function setSelectionMode(mode) {
selectionMode = mode;
if (mode === 'polygon') {
polygonSelectBtn.classList.add('active');
boxSelectBtn.classList.remove('active');
setStatus(editorStatus, 'Polygon select mode. Click to draw, dbl-click to finish.');
isDrawingPolygon = false;
polygonPoints = [];
} else { // box mode
boxSelectBtn.classList.add('active');
polygonSelectBtn.classList.remove('active');
setStatus(editorStatus, 'Box select mode. Hold Shift and drag to select.');
isDrawingPolygon = false; // Cancel any ongoing polygon drawing
}
draw();
}
boxSelectBtn.addEventListener('click', () => setSelectionMode('box'));
polygonSelectBtn.addEventListener('click', () => setSelectionMode('polygon'));
updateSelectionBtn.addEventListener('click', async () => {
if (selection.size === 0) {
setStatus(editorStatus, 'No dies selected to update.', true);
@@ -361,19 +488,30 @@ document.addEventListener('DOMContentLoaded', () => {
const result = await response.json();
if (!response.ok) throw new Error(result.error);
waferMap = result.wafer_map;
selection.clear();
updateBinCounts();
handleMapUpdate(result);
setStatus(editorStatus, 'Update successful.');
draw();
updateHistoryButtons(true, false);
} catch (error) {
setStatus(editorStatus, `Error: ${error.message}`, true);
}
});
resetViewBtn.addEventListener('click', () => {
resetTransform();
draw();
resetViewBtn.addEventListener('click', async () => {
setStatus(editorStatus, 'Resetting map to original state...');
try {
const response = await fetch('/reset_map', { method: 'POST' });
const result = await response.json();
if (!response.ok) throw new Error(result.error);
handleMapUpdate(result);
resetTransform(); // Also reset zoom and pan
draw();
setStatus(editorStatus, 'Map has been reset.');
updateHistoryButtons(result.can_undo, result.can_redo);
} catch (error) {
setStatus(editorStatus, `Error: ${error.message}`, true);
}
});
rotateBtn.addEventListener('click', async () => {
@@ -383,19 +521,69 @@ document.addEventListener('DOMContentLoaded', () => {
const result = await response.json();
if (!response.ok) throw new Error(result.error);
waferMap = result.wafer_map;
mapRows = result.rows;
mapCols = result.cols;
selection.clear();
resetTransform();
updateBinCounts();
draw();
handleMapUpdate(result);
setStatus(editorStatus, 'Map rotated successfully.');
updateHistoryButtons(true, false);
} catch (error) {
setStatus(editorStatus, `Error: ${error.message}`, true);
}
});
insetMapBtn.addEventListener('click', async () => {
const layers = document.getElementById('inset-layers').value;
const fromBin = document.getElementById('inset-from-bin').value;
const toBin = document.getElementById('inset-to-bin').value;
if (parseInt(layers) < 1) {
setStatus(editorStatus, 'Inset layers must be at least 1.', true);
return;
}
if (fromBin === toBin) {
setStatus(editorStatus, "'From' and 'To' bin types cannot be the same.", true);
return;
}
setStatus(editorStatus, `Insetting ${layers} layer(s)...`);
try {
const response = await fetch('/inset_map', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
layers: layers,
from_bin: fromBin,
to_bin: toBin
}),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
handleMapUpdate(result);
setStatus(editorStatus, 'Inset operation successful.');
updateHistoryButtons(true, false);
} catch (error) {
setStatus(editorStatus, `Error: ${error.message}`, true);
}
});
async function handleHistoryAction(action) {
setStatus(editorStatus, `Performing ${action}...`);
try {
const response = await fetch(`/${action}`, { method: 'POST' });
const result = await response.json();
if (!response.ok) throw new Error(result.error);
handleMapUpdate(result);
setStatus(editorStatus, `${action} successful.`);
updateHistoryButtons(result.can_undo, result.can_redo);
} catch (error) {
setStatus(editorStatus, `Error: ${error.message}`, true);
}
}
undoBtn.addEventListener('click', () => handleHistoryAction('undo'));
redoBtn.addEventListener('click', () => handleHistoryAction('redo'));
new ResizeObserver(() => {
if (mapRows > 0) { // Only resize if there is a map

92
templates/compare.html Normal file
View File

@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wafer Map Comparison</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="app-container">
<!-- Sidebar for Controls -->
<aside class="sidebar">
<header class="sidebar-header">
<h1>Map Comparison</h1>
<a href="/" class="button-secondary" style="text-align: center;">Back to Editor</a>
</header>
<!-- Step 1: Upload Multiple Files -->
<div id="upload-section" class="card active">
<div class="card-header">
<h2>Step 1: Upload Maps</h2>
</div>
<form id="upload-form">
<input type="file" id="file-input" name="files" accept=".txt" multiple required>
<button type="submit" class="button-primary">Upload & Analyze</button>
</form>
<div id="upload-status" class="status"></div>
</div>
<!-- Step 2: Define Bins -->
<div id="bin-definition-section" class="card">
<div class="card-header">
<h2>Step 2: Define Bin Codes</h2>
<p class="hint-text">These definitions will apply to all maps.</p>
</div>
<form id="bin-definition-form">
<div id="bin-map-container"></div>
<button type="submit" class="button-primary">Generate Maps</button>
</form>
<div id="bin-status" class="status"></div>
</div>
<!-- Step 3: Set References & Compare -->
<div id="comparison-controls-section" class="card">
<div class="card-header">
<h2>Step 3: Set References & Compare</h2>
</div>
<div id="reference-setter-container">
<!-- Map reference setters will be dynamically inserted here -->
</div>
<div class="control-group-divider"></div>
<button id="compare-btn" class="button-primary" disabled>Run Comparison</button>
<div id="compare-status" class="status"></div>
</div>
</aside>
<!-- Main Content Area for Canvas -->
<main class="main-content">
<div id="viewer-section" style="display: none;">
<div class="map-viewer-controls">
<label for="map-selector">Select Map:</label>
<select id="map-selector"></select>
<button id="rotate-map-btn" class="button-secondary">Rotate 90°</button>
</div>
<div id="map-container">
<canvas id="wafer-map-canvas"></canvas>
<div id="reference-info" class="status">Click on a die to set its reference point.</div>
</div>
</div>
<div id="comparison-result-section" style="display: none;">
<div class="map-viewer-controls">
<button id="back-to-viewer-btn" class="button-secondary">Back to Viewer</button>
</div>
<div id="result-map-container">
<canvas id="result-map-canvas"></canvas>
<div id="map-tooltip" class="map-tooltip"></div>
</div>
<div id="result-legend" class="counts-container"></div>
</div>
<div id="welcome-message">
<h2>Welcome to Map Comparison</h2>
<p>Please upload two or more map files to begin.</p>
</div>
</main>
</div>
<script src="{{ url_for('static', filename='js/compare.js') }}"></script>
</body>
</html>

View File

@@ -15,6 +15,7 @@
<aside class="sidebar">
<header class="sidebar-header">
<h1>Wafer Map Pro</h1>
<a href="/compare" class="button-secondary" style="text-align: center; margin-top: 10px;">Go to Comparison</a>
</header>
<!-- Step 1: Upload -->
@@ -50,23 +51,72 @@
<h2>Step 3: Actions</h2>
</div>
<div id="controls">
<div class="control-group">
<label for="bin-type-selector">Set Bin Type:</label>
<select id="bin-type-selector">
<option value="good">Good</option>
<option value="ng">NG</option>
<option value="dummy">Dummy</option>
<option value="ignore">Ignore</option>
</select>
<button id="update-selection-btn" class="button-secondary">Apply</button>
<!-- Edit Tools -->
<div class="control-section">
<h3 class="control-section-header">Edit Tools</h3>
<div class="control-group">
<label for="bin-type-selector">Set Bin Type:</label>
<select id="bin-type-selector">
<option value="good">Good</option>
<option value="ng">NG</option>
<option value="dummy">Dummy</option>
<option value="ignore">Ignore</option>
</select>
<button id="update-selection-btn" class="button-secondary">Apply to Selection</button>
</div>
<div class="control-group-divider"></div>
<div class="control-group">
<label>Selection Mode:</label>
<div class="btn-group">
<button id="box-select-btn" class="button-secondary active">Box</button>
<button id="polygon-select-btn" class="button-secondary">Polygon</button>
</div>
</div>
<p class="hint-text">Pro-tip: Hold <strong>Shift</strong> for Box Select, or <strong>Ctrl</strong> for multi-select.</p>
<div class="control-group-divider"></div>
<div class="control-group vertical">
<label>Inset from Edge:</label>
<div class="inset-control-row">
<label for="inset-layers">Layers:</label>
<input type="number" id="inset-layers" value="1" min="1">
</div>
<div class="inset-control-row">
<label for="inset-from-bin">From:</label>
<select id="inset-from-bin">
<option value="good">Good</option>
<option value="ng">NG</option>
<option value="dummy">Dummy</option>
</select>
</div>
<div class="inset-control-row">
<label for="inset-to-bin">To:</label>
<select id="inset-to-bin">
<option value="ng">NG</option>
<option value="good">Good</option>
<option value="dummy">Dummy</option>
</select>
</div>
<button id="inset-map-btn" class="button-secondary">Run Inset</button>
</div>
</div>
<div class="control-group-grid">
<button id="rotate-btn" class="button-secondary">Rotate 90°</button>
<button id="reset-view-btn" class="button-secondary">Reset View</button>
<!-- View Tools -->
<div class="control-section">
<h3 class="control-section-header">View Tools</h3>
<div class="control-group-grid four-cols">
<button id="undo-btn" class="button-secondary" disabled>Undo</button>
<button id="redo-btn" class="button-secondary" disabled>Redo</button>
<button id="rotate-btn" class="button-secondary">Rotate 90°</button>
<button id="reset-view-btn" class="button-secondary">Reset Map</button>
</div>
</div>
<!-- File Actions -->
<div class="control-section">
<h3 class="control-section-header">File</h3>
<a id="download-btn" class="button-primary" href="/download" download>Download .txt</a>
</div>
</div>
<p class="hint-text">Pro-tip: Hold <strong>Shift</strong> and drag to select a region.</p>
</div>
</aside>

View File

@@ -60,4 +60,161 @@ def save_wafer_map(wafer_map, bin_to_char_mapping, file_path):
"""
with open(file_path, 'w', encoding='utf-8') as file:
for row in wafer_map:
file.write(''.join([bin_to_char_mapping.get(int(bin_code), ' ') for bin_code in row]) + '\n')
file.write(''.join([bin_to_char_mapping.get(int(bin_code), ' ') for bin_code in row]) + '\n')
def get_bin_counts(wafer_map):
"""
Calculates the count of each bin type in the wafer map.
"""
unique, counts = np.unique(wafer_map, return_counts=True)
return dict(zip(unique, counts))
def inset_wafer_map(wafer_map, layers, from_bin_value, to_bin_value):
"""
Finds the outer N layers of the main die area, defined by proximity to
'ignore' bins, and changes them from 'from_bin_value' to 'to_bin_value'.
"""
if layers <= 0:
return wafer_map
rows, cols = wafer_map.shape
# Mask for all dies that are candidates for changing.
target_mask = (wafer_map == from_bin_value)
# Mask for dies that are NOT part of the wafer (e.g., ignore bins)
background_mask = (wafer_map == -1)
# This will hold all dies that have been identified for changing.
final_change_mask = np.zeros_like(wafer_map, dtype=bool)
# This mask represents the "peeling front". Initially, it's the background.
peel_front_mask = np.copy(background_mask)
for _ in range(layers):
# Find dies in our target_mask that are adjacent to the current peel_front_mask
newly_found_edge_mask = np.zeros_like(wafer_map, dtype=bool)
# Iterate only over the candidates to be more efficient
for r, c in np.argwhere(target_mask):
# Check neighbors
for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
nr, nc = r + dr, c + dc
# If a neighbor is part of the current "peel front"
if (0 <= nr < rows and 0 <= nc < cols and peel_front_mask[nr, nc]):
newly_found_edge_mask[r, c] = True
break # Found it's an edge, no need to check other neighbors
# If we didn't find any new edge dies, we're done.
if not np.any(newly_found_edge_mask):
break
# Add the newly found layer to our final mask of dies to change.
final_change_mask |= newly_found_edge_mask
# The layer we just found becomes the new "peel front" for the next iteration.
peel_front_mask |= newly_found_edge_mask
# Remove the dies we just processed from the pool of candidates.
target_mask &= ~newly_found_edge_mask
# Apply the change to a copy of the original map
wafer_map_copy = np.copy(wafer_map)
wafer_map_copy[final_change_mask] = to_bin_value
return wafer_map_copy
def compare_wafer_maps(maps, references):
"""
Compares multiple wafer maps by aligning them based on reference points.
"""
if not maps or len(maps) != len(references):
return np.array([]), {}
# Determine the bounds of the combined map
max_up = 0
max_down = 0
max_left = 0
max_right = 0
for i, (map_arr, ref) in enumerate(zip(maps, references)):
ref_r, ref_c = ref
rows, cols = map_arr.shape
max_up = max(max_up, ref_r)
max_down = max(max_down, rows - ref_r - 1)
max_left = max(max_left, ref_c)
max_right = max(max_right, cols - ref_c - 1)
# Total dimensions of the comparison grid
total_rows = max_up + max_down + 1
total_cols = max_left + max_right + 1
# A grid where each cell holds a list of bin codes from all maps at that position
# Initialize with a special value for "no map data"
NO_DATA = -10
comparison_grid = [[[NO_DATA] * len(maps) for _ in range(total_cols)] for _ in range(total_rows)]
# Place each map onto the comparison grid
for i, (map_arr, ref) in enumerate(zip(maps, references)):
ref_r, ref_c = ref
rows, cols = map_arr.shape
offset_r = max_up - ref_r
offset_c = max_left - ref_c
for r in range(rows):
for c in range(cols):
# Only consider non-ignore bins
if map_arr[r, c] != -1:
comparison_grid[offset_r + r][offset_c + c][i] = map_arr[r, c]
# --- Process the grid to create a final result map ---
# Result codes:
# -1: No data from any map (Ignore)
# 0: Match (Good)
# 1: Match (NG)
# 2: Match (Dummy)
# 3: Mismatch (mix of Good, NG, Dummy)
# 4: Partial data (some maps have data, others don't)
result_map = np.full((total_rows, total_cols), -1, dtype=int)
for r in range(total_rows):
for c in range(total_cols):
bins = [b for b in comparison_grid[r][c] if b != NO_DATA]
if not bins: # No map has data here
result_map[r, c] = -1
continue
if len(bins) < len(maps): # Some maps have data, others don't
result_map[r, c] = 4
continue
# All maps have data, check for match or mismatch
first_bin = bins[0]
is_match = all(b == first_bin for b in bins)
if is_match:
if first_bin == 2: # Good
result_map[r, c] = 0
elif first_bin == 1: # NG
result_map[r, c] = 1
elif first_bin == 0: # Dummy
result_map[r, c] = 2
else: # Mismatch
result_map[r, c] = 3
legend = {
-1: {"label": "No Data", "color": "#3e3e42"},
0: {"label": "Match (Good)", "color": "#28a745"},
1: {"label": "Match (NG)", "color": "#4a90e2"}, # Different color for match NG
2: {"label": "Match (Dummy)", "color": "#7f8c8d"},
3: {"label": "Mismatch", "color": "#dc3545"},
4: {"label": "Partial Data", "color": "#f39c12"}
}
# Also return the detailed grid for tooltip purposes
return result_map, legend, comparison_grid