Add wafer layouts and RBF heatmap functionality
- Introduced new YAML layout files for wafers B, C, D, F, X, Z, and their reversed versions. - Implemented RBF heatmap interpolation using CuPy and NumPy for GPU acceleration. - Created a backend module to load wafer layouts from YAML files, mirroring the existing schema. - Developed a QQuickPaintedItem for rendering wafer maps, including sensor markers, heatmaps, and labels. - Enhanced the drawing capabilities with concentric rings, crosshair axes, and orientation markers.
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: aepwafer
|
||||||
|
wafers: ["A", "E", "P"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: large
|
||||||
|
font_size: large
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: left
|
||||||
|
y_origin: bottom
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [150, 204, 249, 280, 290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97,
|
||||||
|
150, 203, 241, 255, 241, 203, 150, 98, 59, 45, 59, 98, 171, 207, 228, 228,
|
||||||
|
207, 171, 130, 94, 73, 73, 94, 130, 150, 186, 200, 186, 150, 115, 100, 115]
|
||||||
|
Y: [290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97, 150, 204, 249, 280,
|
||||||
|
255, 241, 203, 150, 98, 59, 45, 59, 98, 150, 203, 241, 228, 207, 171, 130,
|
||||||
|
94, 73, 73, 94, 130, 171, 207, 228, 200, 186, 150, 115, 100, 115, 150, 186]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
4: { top: [0, 0] },
|
||||||
|
5: { top: [0, 0] },
|
||||||
|
6: { top: [0, 0] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: bcdwafer
|
||||||
|
wafers: ["B", "C", "D"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: large
|
||||||
|
font_size: large
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [0, 72.50, 125.57, 145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00,
|
||||||
|
-125.57, -72.50, 0, 47.50, 82.27, 95.00, 82.27, 47.50, 0, -47.50, -82.27,
|
||||||
|
-95.00, -82.27, -47.50, 38.97, 22.50, -38.97, -22.50, 0]
|
||||||
|
Y: [145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00, -125.57, -72.50, 0,
|
||||||
|
72.50, 125.57, 95.00, 82.27, 47.50, 0, -47.50, -82.27, -95.00, -82.27,
|
||||||
|
-47.50, 0, 47.50, 82.27, 22.50, -38.97, -22.50, 38.97, 0]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
2: { left: [1, 0] },
|
||||||
|
3: { left: [1, 0] },
|
||||||
|
4: { left: [1, 0] },
|
||||||
|
5: { left: [1, 0] },
|
||||||
|
6: { left: [1, 0] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: fwafer
|
||||||
|
wafers: ["F"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 200
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: large
|
||||||
|
font_size: large
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [0.0, 47.5, 82.3, 95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5,
|
||||||
|
-55.0, -14.8, 39.7, 55.0, 14.8, -39.7, -14.7, 25.5, 14.7, -25.5]
|
||||||
|
Y: [95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5, 0.0, 47.5, 82.3,
|
||||||
|
13.3, 54.4, 40.0, -13.3, -54.4, -40.0, 25.5, 14.7, -25.5, -14.7]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
2: { left: [1, 0] },
|
||||||
|
3: { left: [1, 0] },
|
||||||
|
4: { left: [1, 0] },
|
||||||
|
5: { left: [1, 0] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: xwafer
|
||||||
|
wafers: ["X"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: square
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 310
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: small
|
||||||
|
font_size: small
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: left
|
||||||
|
y_origin: bottom
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [ 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
|
||||||
|
13.00, 23.00, 46.43, 89.86, 133.29, 176.72, 220.15, 263.58, 287.01, 297.01,
|
||||||
|
307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01,
|
||||||
|
307.01, 307.01, 307.01, 297.01, 287.01, 263.58, 220.15, 176.72, 133.29,
|
||||||
|
89.86, 46.43, 23.00, 13.00, 46.43, 46.43, 46.43, 46.43, 46.43, 46.43,
|
||||||
|
89.86, 133.29, 176.72, 220.15, 263.58, 263.58, 263.58, 263.58, 263.58,
|
||||||
|
263.58, 220.15, 176.72, 133.29, 89.86, 89.86, 89.86, 89.86, 89.86,
|
||||||
|
133.29, 176.72, 220.15, 220.15, 220.15, 220.15, 176.72, 133.29, 133.29,
|
||||||
|
133.29, 176.72, 176.72]
|
||||||
|
Y: [ 3.00, 13.07, 23.07, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65, 287.08,
|
||||||
|
297.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08,
|
||||||
|
307.08, 307.08, 307.08, 307.08, 297.08, 287.08, 263.65, 220.22, 176.79,
|
||||||
|
133.36, 89.93, 46.50, 23.07, 13.07, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
|
||||||
|
3.00, 3.00, 3.00, 3.00, 3.00, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65,
|
||||||
|
263.65, 263.65, 263.65, 263.65, 263.65, 220.22, 176.79, 133.36, 89.93,
|
||||||
|
46.50, 46.50, 46.50, 46.50, 46.50, 89.93, 133.36, 176.79, 220.22, 220.22,
|
||||||
|
220.22, 220.22, 176.79, 133.36, 89.93, 89.93, 89.93, 133.36, 176.79,
|
||||||
|
176.79, 133.36 ]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
21: { left: [0, 0] },
|
||||||
|
22: { left: [0, 0] },
|
||||||
|
23: { left: [0, 0] },
|
||||||
|
24: { left: [0, 0] },
|
||||||
|
25: { left: [0, 0] },
|
||||||
|
26: { left: [0, 0] },
|
||||||
|
27: { left: [0, 0] },
|
||||||
|
28: { left: [0, 0] },
|
||||||
|
29: { left: [0, 0] },
|
||||||
|
30: { left: [0, 0] },
|
||||||
|
31: { left: [0, 0] },
|
||||||
|
32: { left: [0, 0] },
|
||||||
|
33: { left: [0, 0] },
|
||||||
|
34: { left: [0, 0] },
|
||||||
|
35: { left: [0, 0] },
|
||||||
|
36: { left: [0, 0] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: zwafer
|
||||||
|
wafers: ["Z"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: small
|
||||||
|
font_size: small
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top probably also needs
|
||||||
|
# reverse_x/reverse_y to be set as well.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the wafer center
|
||||||
|
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
|
||||||
|
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
|
||||||
|
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
|
||||||
|
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
|
||||||
|
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
|
||||||
|
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
|
||||||
|
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
|
||||||
|
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
|
||||||
|
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
|
||||||
|
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
|
||||||
|
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
|
||||||
|
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
|
||||||
|
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
|
||||||
|
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 0
|
||||||
|
|
||||||
|
# If one or both axes are reversed, this can be used to fix it
|
||||||
|
# without having to edit all of the coordinates.
|
||||||
|
# These should be false for most normal wafers.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
33: { left: [0, 0] },
|
||||||
|
34: { left: [0, 0] },
|
||||||
|
41: { top: [0, 0] },
|
||||||
|
42: { bottom: [0, 0] },
|
||||||
|
45: { top: [1, 0] },
|
||||||
|
46: { bottom: [0.5, 0] },
|
||||||
|
49: { left: [0, 0] },
|
||||||
|
50: { bottom: [-1.75, -0.5] },
|
||||||
|
51: { top: [-1, 0] },
|
||||||
|
52: { top: [-1, 0] },
|
||||||
|
53: { bottom: [0, 0] },
|
||||||
|
54: { left: [0, -0.75] },
|
||||||
|
55: { right: [0, 0.5] },
|
||||||
|
58: { bottom: [-0.75, 0] },
|
||||||
|
60: { top: [0, 0] },
|
||||||
|
62: { right: [0, 1] },
|
||||||
|
63: { right: [0, 0] },
|
||||||
|
64: { left: [0, 0.25] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: zwafer_rev
|
||||||
|
wafers: [] # the reversed version doesn't represent any real wafer
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: small
|
||||||
|
font_size: small
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top probably also needs
|
||||||
|
# reverse_x/reverse_y to be set as well.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the wafer center
|
||||||
|
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
|
||||||
|
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
|
||||||
|
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
|
||||||
|
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
|
||||||
|
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
|
||||||
|
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
|
||||||
|
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
|
||||||
|
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
|
||||||
|
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
|
||||||
|
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
|
||||||
|
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
|
||||||
|
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
|
||||||
|
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
|
||||||
|
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 0
|
||||||
|
|
||||||
|
# If one or both axes are reversed, this can be used to fix it
|
||||||
|
# without having to edit all of the coordinates.
|
||||||
|
# These should be false for most normal wafers.
|
||||||
|
reverse_x: true
|
||||||
|
reverse_y: true
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
37: { left: [0, 0] },
|
||||||
|
38: { left: [0, 0] },
|
||||||
|
39: { left: [0, 0] },
|
||||||
|
41: { top: [1.5, 0] },
|
||||||
|
42: { bottom: [1, 0] },
|
||||||
|
43: { left: [0, -1] },
|
||||||
|
45: { bottom: [-1, -1] },
|
||||||
|
46: { bottom: [0, -1] },
|
||||||
|
47: { right: [0, 1] },
|
||||||
|
48: { left: [0, 0] },
|
||||||
|
50: { bottom: [-1, 0] },
|
||||||
|
52: { top: [0, 0] },
|
||||||
|
54: { right: [0.5, 1] },
|
||||||
|
56: { left: [0, 0.5] },
|
||||||
|
57: { left: [0, 0] },
|
||||||
|
58: { top: [0.5, 0] },
|
||||||
|
59: { left: [0, 0] },
|
||||||
|
60: { top: [-1, 0] },
|
||||||
|
61: { left: [0, 0] },
|
||||||
|
62: { left: [0, -1] },
|
||||||
|
63: { right: [0, 1] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""RBF (thin-plate spline) heatmap field.
|
||||||
|
|
||||||
|
Uses CuPy for GPU acceleration when available, falls back to NumPy + SciPy.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.interpolate import RBFInterpolator
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cupy as _cupy # type: ignore
|
||||||
|
BACKEND = "cupy"
|
||||||
|
except Exception:
|
||||||
|
_cupy = None
|
||||||
|
BACKEND = "numpy"
|
||||||
|
|
||||||
|
_KERNEL = "thin_plate_spline"
|
||||||
|
_SMOOTHING = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def interpolate_field(
|
||||||
|
xs: np.ndarray,
|
||||||
|
ys: np.ndarray,
|
||||||
|
vs: np.ndarray,
|
||||||
|
*,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm
|
||||||
|
round_clip: bool = False,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Return a (height, width) float64 array of interpolated values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xs, ys: sensor positions in mm (1-D arrays, length N)
|
||||||
|
vs: sensor values (length N)
|
||||||
|
width/height: output grid dimensions in pixels
|
||||||
|
extent: (xmin, xmax, ymin, ymax) in the same mm space as xs/ys
|
||||||
|
round_clip: if True, pixels outside the inscribed ellipse become NaN
|
||||||
|
"""
|
||||||
|
coords = np.column_stack([xs, ys])
|
||||||
|
rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING)
|
||||||
|
|
||||||
|
xmin, xmax, ymin, ymax = extent
|
||||||
|
gx = np.linspace(xmin, xmax, width)
|
||||||
|
gy = np.linspace(ymin, ymax, height)
|
||||||
|
grid_x, grid_y = np.meshgrid(gx, gy)
|
||||||
|
flat = np.column_stack([grid_x.ravel(), grid_y.ravel()])
|
||||||
|
|
||||||
|
# RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later
|
||||||
|
field = rbf(flat).reshape(height, width)
|
||||||
|
|
||||||
|
if round_clip:
|
||||||
|
cx = (xmin + xmax) / 2
|
||||||
|
cy = (ymin + ymax) / 2
|
||||||
|
rx = (xmax - xmin) / 2
|
||||||
|
ry = (ymax - ymin) / 2
|
||||||
|
dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2
|
||||||
|
field = np.where(dist <= 1.0, field, np.nan)
|
||||||
|
|
||||||
|
return field.astype(np.float64)
|
||||||
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Load wafer sensor layouts from bundled YAML files.
|
||||||
|
|
||||||
|
YAML schema mirrors the replay app's wafer_desc.py format:
|
||||||
|
X/Y — sensor positions in mm relative to x_origin/y_origin
|
||||||
|
size — wafer diameter/edge in mm
|
||||||
|
shape — "round" or "square"
|
||||||
|
start_sn — first sensor number (usually 1)
|
||||||
|
x_origin — "left" | "right" | "center"
|
||||||
|
y_origin — "bottom" | "top" | "center"
|
||||||
|
|
||||||
|
Returned Sensor coords are center-origin mm (negative values toward the edge).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from pygui.backend.zwafer_models import Sensor
|
||||||
|
|
||||||
|
_LAYOUTS_DIR = Path(__file__).parent.parent / "assets" / "layouts"
|
||||||
|
|
||||||
|
|
||||||
|
def _family_name(raw_name: str) -> str:
|
||||||
|
return raw_name.replace("wafer", "").strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_yaml(path: Path) -> dict:
|
||||||
|
with path.open(encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def available_families() -> list[str]:
|
||||||
|
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
|
||||||
|
|
||||||
|
|
||||||
|
def load_layout(family: str) -> list[Sensor]:
|
||||||
|
for path in _LAYOUTS_DIR.glob("*.yaml"):
|
||||||
|
data = _load_yaml(path)
|
||||||
|
if _family_name(data["name"]) == family:
|
||||||
|
return _to_sensors(data)
|
||||||
|
raise KeyError(f"Unknown wafer family: {family!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_layout_for_wafer_id(wafer_id: str) -> list[Sensor]:
|
||||||
|
"""Match 'B00108' → bcdwafer by looking up the first char in each YAML's 'wafers' list."""
|
||||||
|
prefix = wafer_id[0].upper() if wafer_id else ""
|
||||||
|
for path in _LAYOUTS_DIR.glob("*.yaml"):
|
||||||
|
data = _load_yaml(path)
|
||||||
|
if prefix in data.get("wafers", []):
|
||||||
|
return _to_sensors(data)
|
||||||
|
raise KeyError(f"No layout found for wafer ID prefix {prefix!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_sensors(data: dict) -> list[Sensor]:
|
||||||
|
xs: list[float] = data["X"]
|
||||||
|
ys: list[float] = data["Y"]
|
||||||
|
size: float = float(data["size"])
|
||||||
|
start_sn: int = data.get("start_sn", 1)
|
||||||
|
reverse_x: bool = data.get("reverse_x", False)
|
||||||
|
reverse_y: bool = data.get("reverse_y", False)
|
||||||
|
x_orig: str = data.get("x_origin", "left")
|
||||||
|
y_orig: str = data.get("y_origin", "bottom")
|
||||||
|
|
||||||
|
x_shift = {"left": size / 2, "right": -(size / 2), "center": 0.0}.get(x_orig, 0.0)
|
||||||
|
y_shift = {"bottom": size / 2, "top": -(size / 2), "center": 0.0}.get(y_orig, 0.0)
|
||||||
|
|
||||||
|
sensors: list[Sensor] = []
|
||||||
|
for i, (x_mm, y_mm) in enumerate(zip(xs, ys)):
|
||||||
|
if reverse_x:
|
||||||
|
x_mm = -x_mm
|
||||||
|
if reverse_y:
|
||||||
|
y_mm = -y_mm
|
||||||
|
sensors.append(Sensor(
|
||||||
|
label=str(start_sn + i),
|
||||||
|
x=x_mm - x_shift,
|
||||||
|
y=y_mm - y_shift,
|
||||||
|
))
|
||||||
|
return sensors
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
"""QQuickPaintedItem wafer map — ported from the replay app's ReplayWidget.
|
||||||
|
|
||||||
|
Draws:
|
||||||
|
• Radial ring template (concentric guides + crosshair axes + top notch)
|
||||||
|
• RBF heatmap layer (blended under markers via `blend` 0→1)
|
||||||
|
• Sensor marker circles colored by band (low/in_range/high)
|
||||||
|
• Numbered labels (toggle via `showLabels`)
|
||||||
|
|
||||||
|
All sensor coordinates are center-origin mm (from wafer_layouts or a loaded CSV).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PySide6.QtCore import Property, QPoint, Signal, Slot, Qt
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QBrush, QColor, QFont, QImage, QPainter, QPen, QPolygon,
|
||||||
|
)
|
||||||
|
from PySide6.QtQml import QmlElement
|
||||||
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
|
from pygui.backend.rbf_heatmap import interpolate_field
|
||||||
|
from pygui.backend.zwafer_models import Sensor
|
||||||
|
|
||||||
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
@QmlElement
|
||||||
|
class WaferMapItem(QQuickPaintedItem):
|
||||||
|
"""Painted wafer map; driven by SessionController via QML property bindings."""
|
||||||
|
|
||||||
|
sensorsChanged = Signal()
|
||||||
|
valuesChanged = Signal()
|
||||||
|
bandsChanged = Signal()
|
||||||
|
targetChanged = Signal()
|
||||||
|
marginChanged = Signal()
|
||||||
|
blendChanged = Signal()
|
||||||
|
showLabelsChanged = Signal()
|
||||||
|
colorsChanged = Signal()
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._sensors: list[Sensor] = []
|
||||||
|
self._values: list[float] = []
|
||||||
|
self._bands: list[str] = []
|
||||||
|
self._target: float = 149.0
|
||||||
|
self._margin: float = 1.0
|
||||||
|
self._blend: float = 0.0
|
||||||
|
self._show_labels: bool = True
|
||||||
|
|
||||||
|
# Dark-theme color defaults (match Theme.qml tokens)
|
||||||
|
self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder)
|
||||||
|
self._axis_color = QColor("#3A4D5C") # waferAxisColor (softBorder)
|
||||||
|
self._low_color = QColor("#5B9DF5") # sensorLow
|
||||||
|
self._in_range_color = QColor("#22C55E") # sensorInRange
|
||||||
|
self._high_color = QColor("#EF4444") # sensorHigh
|
||||||
|
self._text_color = QColor("#CBD5E1") # bodyColor
|
||||||
|
|
||||||
|
# Internal draw state
|
||||||
|
self._markers: dict[int, tuple[int, int]] = {} # sensor index → (px, py)
|
||||||
|
self._marker_r: int = 4
|
||||||
|
self._heatmap: QImage | None = None
|
||||||
|
|
||||||
|
self.widthChanged.connect(self._on_resize)
|
||||||
|
self.heightChanged.connect(self._on_resize)
|
||||||
|
|
||||||
|
# ── Qt properties ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=sensorsChanged)
|
||||||
|
def sensors(self) -> list:
|
||||||
|
return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors]
|
||||||
|
|
||||||
|
@sensors.setter
|
||||||
|
def sensors(self, val: list) -> None:
|
||||||
|
self._sensors = [Sensor(label=d["label"], x=float(d["x"]), y=float(d["y"]))
|
||||||
|
for d in (val or [])]
|
||||||
|
self._rebuild()
|
||||||
|
self.sensorsChanged.emit()
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=valuesChanged)
|
||||||
|
def values(self) -> list:
|
||||||
|
return self._values
|
||||||
|
|
||||||
|
@values.setter
|
||||||
|
def values(self, val: list) -> None:
|
||||||
|
self._values = list(val or [])
|
||||||
|
self._rebuild_heatmap()
|
||||||
|
self.valuesChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=bandsChanged)
|
||||||
|
def bands(self) -> list:
|
||||||
|
return self._bands
|
||||||
|
|
||||||
|
@bands.setter
|
||||||
|
def bands(self, val: list) -> None:
|
||||||
|
self._bands = list(val or [])
|
||||||
|
self.bandsChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(float, notify=targetChanged)
|
||||||
|
def target(self) -> float:
|
||||||
|
return self._target
|
||||||
|
|
||||||
|
@target.setter
|
||||||
|
def target(self, val: float) -> None:
|
||||||
|
self._target = float(val)
|
||||||
|
self._rebuild_heatmap()
|
||||||
|
self.targetChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(float, notify=marginChanged)
|
||||||
|
def margin(self) -> float:
|
||||||
|
return self._margin
|
||||||
|
|
||||||
|
@margin.setter
|
||||||
|
def margin(self, val: float) -> None:
|
||||||
|
self._margin = float(val)
|
||||||
|
self._rebuild_heatmap()
|
||||||
|
self.marginChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(float, notify=blendChanged)
|
||||||
|
def blend(self) -> float:
|
||||||
|
return self._blend
|
||||||
|
|
||||||
|
@blend.setter
|
||||||
|
def blend(self, val: float) -> None:
|
||||||
|
self._blend = max(0.0, min(1.0, float(val)))
|
||||||
|
if self._blend > 0 and self._heatmap is None:
|
||||||
|
self._rebuild_heatmap()
|
||||||
|
self.blendChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(bool, notify=showLabelsChanged)
|
||||||
|
def showLabels(self) -> bool:
|
||||||
|
return self._show_labels
|
||||||
|
|
||||||
|
@showLabels.setter
|
||||||
|
def showLabels(self, val: bool) -> None:
|
||||||
|
self._show_labels = bool(val)
|
||||||
|
self.showLabelsChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# Colour properties — QML can bind these to Theme tokens
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def ringColor(self) -> QColor: return self._ring_color
|
||||||
|
@ringColor.setter
|
||||||
|
def ringColor(self, c: QColor) -> None: self._ring_color = c; self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def axisColor(self) -> QColor: return self._axis_color
|
||||||
|
@axisColor.setter
|
||||||
|
def axisColor(self, c: QColor) -> None: self._axis_color = c; self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def lowColor(self) -> QColor: return self._low_color
|
||||||
|
@lowColor.setter
|
||||||
|
def lowColor(self, c: QColor) -> None: self._low_color = c; self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def inRangeColor(self) -> QColor: return self._in_range_color
|
||||||
|
@inRangeColor.setter
|
||||||
|
def inRangeColor(self, c: QColor) -> None: self._in_range_color = c; self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def highColor(self) -> QColor: return self._high_color
|
||||||
|
@highColor.setter
|
||||||
|
def highColor(self, c: QColor) -> None: self._high_color = c; self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def textColor(self) -> QColor: return self._text_color
|
||||||
|
@textColor.setter
|
||||||
|
def textColor(self, c: QColor) -> None: self._text_color = c; self.update()
|
||||||
|
|
||||||
|
# ── slots ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Slot(float, float, result=int)
|
||||||
|
def which_marker(self, x: float, y: float) -> int:
|
||||||
|
"""Return the sensor index nearest to (x, y) within marker radius, else -1."""
|
||||||
|
r = max(self._marker_r, 6)
|
||||||
|
for idx, (mx, my) in self._markers.items():
|
||||||
|
if abs(mx - x) <= r and abs(my - y) <= r:
|
||||||
|
return idx
|
||||||
|
return -1
|
||||||
|
|
||||||
|
# ── internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_resize(self) -> None:
|
||||||
|
self._rebuild()
|
||||||
|
|
||||||
|
def _rebuild(self) -> None:
|
||||||
|
self._compute_markers()
|
||||||
|
self._rebuild_heatmap()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _draw_size(self) -> int:
|
||||||
|
return max(1, int(min(self.width(), self.height())))
|
||||||
|
|
||||||
|
def _center(self) -> tuple[int, int]:
|
||||||
|
return int(self.width() / 2), int(self.height() / 2)
|
||||||
|
|
||||||
|
def _wafer_radius_mm(self) -> float:
|
||||||
|
"""Radius of the wafer bounding circle in mm (5% padding beyond outermost sensor)."""
|
||||||
|
if not self._sensors:
|
||||||
|
return 150.0
|
||||||
|
r = max(math.hypot(s.x, s.y) for s in self._sensors)
|
||||||
|
return r * 1.05
|
||||||
|
|
||||||
|
def _sensor_ring_radii_mm(self) -> list[float]:
|
||||||
|
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
|
||||||
|
if not self._sensors:
|
||||||
|
r = self._wafer_radius_mm()
|
||||||
|
return [r * f for f in (0.25, 0.50, 0.75, 1.0)]
|
||||||
|
# Cluster radii that are within 2 mm of each other into one ring; skip center point.
|
||||||
|
radii = sorted(r for r in {math.hypot(s.x, s.y) for s in self._sensors} if r > 1.0)
|
||||||
|
groups: list[float] = []
|
||||||
|
for r in radii:
|
||||||
|
if not groups or r - groups[-1] > 2.0:
|
||||||
|
groups.append(r)
|
||||||
|
else:
|
||||||
|
groups[-1] = (groups[-1] + r) / 2 # merge close values
|
||||||
|
# Always include the outer boundary ring so the wafer circle is drawn.
|
||||||
|
outer = self._wafer_radius_mm()
|
||||||
|
if not groups or outer - groups[-1] > 2.0:
|
||||||
|
groups.append(outer)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
def _scale(self, ds: int, r_mm: float) -> float:
|
||||||
|
"""Pixels per mm. The wafer radius maps to ds//2 - 4 px."""
|
||||||
|
return (ds / 2 - 4) / r_mm
|
||||||
|
|
||||||
|
def _to_px(self, x_mm: float, y_mm: float, cx: int, cy: int, scale: float) -> tuple[int, int]:
|
||||||
|
"""Center-origin mm → pixel (top-left origin). Y is flipped."""
|
||||||
|
return cx + int(x_mm * scale), cy - int(y_mm * scale)
|
||||||
|
|
||||||
|
def _compute_markers(self) -> None:
|
||||||
|
ds = self._draw_size()
|
||||||
|
r_mm = self._wafer_radius_mm()
|
||||||
|
sc = self._scale(ds, r_mm)
|
||||||
|
cx, cy = self._center()
|
||||||
|
self._marker_r = max(3, ds // 70)
|
||||||
|
self._markers = {i: self._to_px(s.x, s.y, cx, cy, sc)
|
||||||
|
for i, s in enumerate(self._sensors)}
|
||||||
|
|
||||||
|
def _rebuild_heatmap(self) -> None:
|
||||||
|
if not self._sensors or not self._values or self._blend == 0.0:
|
||||||
|
self._heatmap = None
|
||||||
|
return
|
||||||
|
ds = self._draw_size()
|
||||||
|
r_mm = self._wafer_radius_mm()
|
||||||
|
xs = np.array([s.x for s in self._sensors])
|
||||||
|
ys = np.array([s.y for s in self._sensors])
|
||||||
|
vs = np.array(self._values[:len(self._sensors)], dtype=float)
|
||||||
|
if len(vs) < len(self._sensors):
|
||||||
|
self._heatmap = None
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
field = interpolate_field(
|
||||||
|
xs, ys, vs,
|
||||||
|
width=ds, height=ds,
|
||||||
|
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
||||||
|
round_clip=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._heatmap = None
|
||||||
|
return
|
||||||
|
self._heatmap = self._field_to_qimage(field, ds)
|
||||||
|
|
||||||
|
def _field_to_qimage(self, field: np.ndarray, ds: int) -> QImage:
|
||||||
|
"""Apply a band-aware tri-color gradient → RGBA QImage."""
|
||||||
|
lo_b = self._target - self._margin
|
||||||
|
hi_b = self._target + self._margin
|
||||||
|
span = hi_b - lo_b or 1.0
|
||||||
|
# t: 0 = lo_b, 1 = hi_b (clipped)
|
||||||
|
t = np.clip((field - lo_b) / span, 0.0, 1.0)
|
||||||
|
|
||||||
|
def c(q: QColor) -> np.ndarray:
|
||||||
|
return np.array([q.redF(), q.greenF(), q.blueF()], dtype=np.float32)
|
||||||
|
|
||||||
|
lo_c = c(self._low_color)
|
||||||
|
mid_c = c(self._in_range_color)
|
||||||
|
hi_c = c(self._high_color)
|
||||||
|
|
||||||
|
t2 = t * 2 # 0→2 across full range
|
||||||
|
|
||||||
|
lower = t <= 0.5
|
||||||
|
t_lo = np.clip(t2, 0.0, 1.0)[:, :, np.newaxis] # 0→1 in lower half
|
||||||
|
t_hi = np.clip(t2 - 1.0, 0.0, 1.0)[:, :, np.newaxis] # 0→1 in upper half
|
||||||
|
|
||||||
|
rgb = np.where(
|
||||||
|
lower[:, :, np.newaxis],
|
||||||
|
lo_c * (1 - t_lo) + mid_c * t_lo,
|
||||||
|
mid_c * (1 - t_hi) + hi_c * t_hi,
|
||||||
|
)
|
||||||
|
# Outside the wafer circle `field` is NaN → NaN propagates into rgb. Alpha
|
||||||
|
# masks those pixels anyway, but zero them so the uint8 cast is well-defined.
|
||||||
|
rgb = np.nan_to_num(rgb, nan=0.0)
|
||||||
|
rgba = np.zeros((ds, ds, 4), dtype=np.uint8)
|
||||||
|
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
||||||
|
rgba[:, :, 3] = np.where(np.isfinite(field), 210, 0).astype(np.uint8)
|
||||||
|
|
||||||
|
return QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy()
|
||||||
|
|
||||||
|
# ── paint ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def paint(self, painter: QPainter) -> None:
|
||||||
|
ds = self._draw_size()
|
||||||
|
r_px = int(ds / 2 - 4)
|
||||||
|
cx, cy = self._center()
|
||||||
|
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
|
||||||
|
self._paint_template(painter, cx, cy, r_px)
|
||||||
|
|
||||||
|
if self._heatmap and self._blend > 0.0:
|
||||||
|
painter.setOpacity(self._blend)
|
||||||
|
painter.drawImage(cx - self._heatmap.width() // 2,
|
||||||
|
cy - self._heatmap.height() // 2, self._heatmap)
|
||||||
|
painter.setOpacity(1.0)
|
||||||
|
|
||||||
|
self._paint_markers(painter)
|
||||||
|
|
||||||
|
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
|
||||||
|
ds = self._draw_size()
|
||||||
|
r_mm = self._wafer_radius_mm()
|
||||||
|
sc = self._scale(ds, r_mm)
|
||||||
|
|
||||||
|
# Concentric rings at actual sensor group radii (falls back to 25/50/75/100% when no sensors).
|
||||||
|
ring_pen = QPen(self._ring_color, 1, Qt.PenStyle.SolidLine)
|
||||||
|
painter.setPen(ring_pen)
|
||||||
|
for ring_r_mm in self._sensor_ring_radii_mm():
|
||||||
|
rr = max(1, int(ring_r_mm * sc))
|
||||||
|
painter.drawEllipse(cx - rr, cy - rr, 2 * rr, 2 * rr)
|
||||||
|
|
||||||
|
# Crosshair axes
|
||||||
|
axis_pen = QPen(self._axis_color, 1, Qt.PenStyle.DashLine)
|
||||||
|
painter.setPen(axis_pen)
|
||||||
|
painter.drawLine(cx, cy - r_px, cx, cy + r_px)
|
||||||
|
painter.drawLine(cx - r_px, cy, cx + r_px, cy)
|
||||||
|
|
||||||
|
# Top notch triangle (wafer orientation marker)
|
||||||
|
nw = max(6, ds // 25)
|
||||||
|
nh = max(4, ds // 35)
|
||||||
|
notch = QPolygon([
|
||||||
|
QPoint(cx, cy - r_px),
|
||||||
|
QPoint(cx - nw // 2, cy - r_px + nh),
|
||||||
|
QPoint(cx + nw // 2, cy - r_px + nh),
|
||||||
|
])
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QBrush(self._axis_color))
|
||||||
|
painter.drawPolygon(notch)
|
||||||
|
|
||||||
|
def _paint_markers(self, painter: QPainter) -> None:
|
||||||
|
r = self._marker_r
|
||||||
|
|
||||||
|
id_font = QFont()
|
||||||
|
id_font.setPointSize(max(5, r))
|
||||||
|
id_font.setBold(True)
|
||||||
|
|
||||||
|
temp_font = QFont()
|
||||||
|
temp_font.setPointSize(max(4, r - 1))
|
||||||
|
|
||||||
|
# Pre-compute ID font metrics for vertical centering
|
||||||
|
painter.setFont(id_font)
|
||||||
|
id_fm = painter.fontMetrics()
|
||||||
|
id_line_h = id_fm.height()
|
||||||
|
id_ascent = id_fm.ascent()
|
||||||
|
|
||||||
|
band_color = {
|
||||||
|
"in_range": self._in_range_color,
|
||||||
|
"high": self._high_color,
|
||||||
|
"low": self._low_color,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, s in enumerate(self._sensors):
|
||||||
|
if i not in self._markers:
|
||||||
|
continue
|
||||||
|
px, py = self._markers[i]
|
||||||
|
color = band_color.get(
|
||||||
|
self._bands[i] if i < len(self._bands) else "in_range",
|
||||||
|
self._in_range_color,
|
||||||
|
)
|
||||||
|
# Filled circle with thin dark outline for contrast over heatmap
|
||||||
|
painter.setPen(QPen(QColor(0, 0, 0, 100), 1))
|
||||||
|
painter.setBrush(QBrush(color))
|
||||||
|
painter.drawEllipse(px - r, py - r, 2 * r, 2 * r)
|
||||||
|
|
||||||
|
if self._show_labels:
|
||||||
|
has_temp = i < len(self._values)
|
||||||
|
lx = px + r + 3
|
||||||
|
# Two-line block: split the gap at dot center; single-line: original position
|
||||||
|
y1 = (py - id_line_h // 2) if has_temp else (py + id_ascent // 2)
|
||||||
|
|
||||||
|
# Sensor ID — bold, muted text color
|
||||||
|
painter.setFont(id_font)
|
||||||
|
painter.setPen(QPen(self._text_color))
|
||||||
|
painter.drawText(lx, y1, s.label)
|
||||||
|
|
||||||
|
# Temperature — band color, smaller font, below ID
|
||||||
|
if has_temp:
|
||||||
|
painter.setFont(temp_font)
|
||||||
|
painter.setPen(QPen(color))
|
||||||
|
painter.drawText(lx, y1 + id_line_h, f"{self._values[i]:.2f}")
|
||||||
Reference in New Issue
Block a user