11 Commits
21 changed files with 10793 additions and 350 deletions
+66 -8
View File
@@ -1,18 +1,22 @@
# comfy-qr
# ComfyQR
A set of ComfyUI nodes to dynamically create QR image layers for generative QR art without the hassle of leaving the webui.
This repository is managed publicly on [Gitlab](https://gitlab.com/sofuego-comfy-nodes/comfy-qr), but also mirrored on [Github](https://github.com/coreyryanhanson/comfy-qr). Please submit any [issues](https://gitlab.com/sofuego-comfy-nodes/comfy-qr/-/issues) or [pull requests](https://gitlab.com/sofuego-comfy-nodes/comfy-qr/-/merge_requests) to the gitlab repo.
Everything in this pack is primarily focused on the creation of patterns. While the `Mask QR Errors` does some basic statistics on the differences between pixels, a more definative node on determining scannability can be found in the companion project [ComfyQR-scanning-nodes](https://gitlab.com/sofuego-comfy-nodes/ComfyQR-scanning-nodes) which uses additional dependencies for the reading of QRs.
![QR code with repo link](img/badgers.png)
This repository is managed publicly on [Gitlab](https://gitlab.com/sofuego-comfy-nodes/ComfyQR), but also mirrored on [Github](https://github.com/coreyryanhanson/ComfyQR). Please submit any [issues](https://gitlab.com/sofuego-comfy-nodes/ComfyQR/-/issues) or [pull requests](https://gitlab.com/sofuego-comfy-nodes/ComfyQR/-/merge_requests) to the gitlab repo.
![QR code with repo link](example_generations/main_qr_inpainted.png)
## Getting started
This project currently contains two nodes for generating QR codes.
### QR Code
### QR Code and QR Code (Split)
A dynamically sized QR code without any resampling scaled by the text length, the module size, and the error correction level.
A dynamically sized QR code without any resampling scaled by the text length, the module size, and the error correction level. Use the `QR Code `for simple workflows and the `QR Code (Split)` if you want to build more advanced pipelines with additional outputs for the `MODULE_LAYER`, `FINDER_LAYER`, or `FINDER_MASK`.
![Split QR Code Node Example](img/node-qr-code-split.png)
#### Inputs
@@ -34,12 +38,17 @@ A dynamically sized QR code without any resampling scaled by the text length, th
#### Outputs
* `IMAGE` - The QR code
* `QR_CODE` - The QR code
* `MODULE_LAYER` - The QR code with the `back_hexcolor` filling in the function patterns (to remove their influence from the early diffusion steps).
* `FINDER_LAYER` - The isolated finder patterns over the `back_hexcolor`.
* `FINDER_MASK` - A mask that isolates the locations of the 3 finder patterns.
* `QR_VERSION` - An integer of the QR version that was selected based on the text length and error correction level.
* `IMAGE_SIZE` - An integer based on the pixel width of the resulting QR code.
### QR Code (Conformed to Image Size)
![Conformed QR Code Node Example](img/node-qr-code-conformed.png)
A QR code fixed to specific output dimensions through image resampling.
#### Inputs
@@ -68,9 +77,58 @@ A QR code fixed to specific output dimensions through image resampling.
#### Outputs
* `IMAGE` - The QR code
* `QR_CODE` - The QR code
* `QR_VERSION` - An integer of the QR version that was selected based on the text length and error correction level.
### Mask QR Errors
A node that that will analyze the differences between a modified QR and create a mask of the estimated errors.
![Mask QR Errors Node Example](img/node-mask-qr-errors.png)
**Note**: _This is an extremly powerful node but relies on several assumptions in order to be used._
1. A QR with a fixed module size that has not been resampled irregularly or distorted.
2. Shared allignment between the `source_qr` and `modified_qr`.
3. Fill and back colors must be perfectly white and black (`#FFFFFF`, `#000000`) for the `source_qr`.
4. The canvas can be bigger than the QR code, but on the `source_qr`, there should be no pixels the same color as the module color outside of the QR exterior.
#### Inputs
* `source_qr` - The original QR code used to generate the image (with the same placement and dimensions as `modified_qr`).
* `modified_qr` - The generated QR art to evaluate (with the same placement and dimensions as `source_qr`).
* `module_size` - The size in pixels of each QR module (the individual blocks that make up a qr code) in the provided images.
* `grayscale_method` - How to convert the RGB color channels to a single grayscale value.
* `mean` - A simple average of the RGB values.
* `luminance` - Converts colors to grayscale using a [more advanced formula](https://en.wikipedia.org/wiki/Grayscale#Colorimetric_(perceptual_luminance-preserving)_conversion_to_grayscale) to better match percieved brightness.
* `aggregate_method` - How to aggregate the many pixels that make up a QR module into a single value.
* `mean` - Within each module a simple average is taken of all constituent pixels.
* `evaluate` - Which part of the QR to examine.
* `full_qr` - The entire QR code is evaluated.
* `module_pattern` - Has the node focus only on the modules while excluding the finder pattern.
* `finder_pattern` - Ignores the modules while examining only the 3 finder patterns at the corners of the QR.
* `error_difficulty` - It is possible that while using the same reader a QR code could be easily scannable on one monitor, but not on another. The way the colors are displayed can have a major effect on unsability. By increasing this threshold, you are simulating inability to distinguish midrange pixels for both the `PERCENT_ERROR` and `QR_ERROR_MASK` outputs. It ranges from 0 to 1 where 0 only automatically fails perfect grays while 1 accepts only exactly matched modules that are perfectly black or white.
* `inverted_pattern` - If set to `True` this assumes that the QR code has inverted colors (a black background with white modules.)
* `gamma` - If using the `grayscale` method based on `luminance`, this will influence the formula by appending the gamma expansion and compression formulas before and after the color conversion. A gamma of 2.2 will use the [formula specific to srgb](qr_nodes.py#0.04045), while other gamma values will simply use the gamma amount as an exponent.
#### Outputs
* `QR_ERROR_MASK` - A mask with white areas falling on locations of modules with errors (precision set by `error_difficulty`). This mask can be used for streamlined img2img operations to salvage unscannable QRs.
* `PERCENT_ERROR` - Number of modules that do not fall into the acceptable threshold (set by `error_difficulty`) divided by the total number of modules.
* `CORRELATION` - The Pearson correlation coefficient between the QR patterns on the `source_qr` and `modified_qr`
* `RMSE` - The root mean squared error between both QR codes. In this case, a value of 0 indicates an identical match to the `source_qr`. A value of 1 indicates a perfect inversion where each expected color is the complete opposite.
## Examples
There is an assortment of workflow examples that can be found in the `examples` directory or in the metadata of the images in the `example_generations` folder. For example taking this image.
![Bad Scan Example](example_generations/unscannable_00001_.png)
And then aggressively increasing the strength of the ControlNet on only the error pixels to make something previously completely unscannable more reliably across different monitors and QR readers.
![Fixed Scan Example](example_generations/unscannable_00001_fixed_.png)
Any workflow in the example that ends with "validated" (and a few image examples) assume the installation of the [scanning pack](https://gitlab.com/sofuego-comfy-nodes/ComfyQR-scanning-nodes) as well.
## Future updates
If the solution is clean enough, I plan on implementing a node that will automatically generate a mask to extract the 3 QR markers (based on `module_size`, `border`, and `QR_VERSION`) for more advanced workflows.
If the solution is clean enough and if it can definitively improve scannability, there may be additional plans for the seperation of alignment patterns (based on `module_size`, `border`, and `QR_VERSION`) for more advanced workflows.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+57 -57
View File
@@ -1,6 +1,6 @@
{
"last_node_id": 13,
"last_link_id": 13,
"last_node_id": 14,
"last_link_id": 14,
"nodes": [
{
"id": 7,
@@ -59,7 +59,7 @@
{
"name": "pixels",
"type": "IMAGE",
"link": 4
"link": 14
},
{
"name": "vae",
@@ -240,51 +240,6 @@
"1.5/v1-5-pruned-emaonly.safetensors"
]
},
{
"id": 4,
"type": "comfy-qr-by-image-size",
"pos": [
202,
581
],
"size": {
"0": 400,
"1": 264
},
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
4
],
"shape": 3,
"slot_index": 0
},
{
"name": "QR_VERSION",
"type": "INT",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "comfy-qr-by-image-size"
},
"widgets_values": [
"Https",
"gitlab.com/sofuego-comfy-nodes/comfy-qr",
512,
"#222222",
"#DDDDDD",
"High",
1,
"Nearest"
]
},
{
"id": 11,
"type": "KSampler",
@@ -336,7 +291,7 @@
"Node name for S&R": "KSampler"
},
"widgets_values": [
321184522216123,
1077540641933383,
"randomize",
20,
8,
@@ -344,6 +299,51 @@
"normal",
0.6499999999999999
]
},
{
"id": 14,
"type": "comfy-qr-by-image-size",
"pos": [
264,
659
],
"size": {
"0": 400,
"1": 264
},
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "QR_CODE",
"type": "IMAGE",
"links": [
14
],
"shape": 3,
"slot_index": 0
},
{
"name": "QR_VERSION",
"type": "INT",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "comfy-qr-by-image-size"
},
"widgets_values": [
"Https",
"gitlab.com/sofuego-comfy-nodes/comfy-qr",
512,
"#000000",
"#DDDDDD",
"High",
1,
"Nearest"
]
}
],
"links": [
@@ -363,14 +363,6 @@
0,
"CLIP"
],
[
4,
4,
0,
9,
0,
"IMAGE"
],
[
5,
6,
@@ -434,6 +426,14 @@
11,
2,
"CONDITIONING"
],
[
14,
14,
0,
9,
0,
"IMAGE"
]
],
"groups": [],
+91 -91
View File
@@ -1,6 +1,6 @@
{
"last_node_id": 18,
"last_link_id": 24,
"last_node_id": 19,
"last_link_id": 27,
"nodes": [
{
"id": 13,
@@ -127,10 +127,10 @@
678,
604
],
"size": [
315,
106
],
"size": {
"0": 315,
"1": 106
},
"flags": {},
"order": 5,
"mode": 0,
@@ -138,7 +138,7 @@
{
"name": "width",
"type": "INT",
"link": 14,
"link": 26,
"widget": {
"name": "width",
"config": [
@@ -155,7 +155,7 @@
{
"name": "height",
"type": "INT",
"link": 15,
"link": 27,
"widget": {
"name": "height",
"config": [
@@ -312,7 +312,7 @@
"Node name for S&R": "KSampler"
},
"widgets_values": [
679629273700059,
123662286732457,
"randomize",
20,
8,
@@ -321,61 +321,6 @@
1
]
},
{
"id": 14,
"type": "comfy-qr-by-module-size",
"pos": [
169,
540
],
"size": {
"0": 400,
"1": 284
},
"flags": {},
"order": 2,
"mode": 0,
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
20
],
"shape": 3,
"slot_index": 0
},
{
"name": "QR_VERSION",
"type": "INT",
"links": null,
"shape": 3
},
{
"name": "IMAGE_SIZE",
"type": "INT",
"links": [
14,
15
],
"shape": 3,
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "comfy-qr-by-module-size"
},
"widgets_values": [
"Https",
"gitlab.com/sofuego-comfy-nodes",
16,
512,
"#000000",
"#999999",
"Medium",
1
]
},
{
"id": 8,
"type": "CLIPTextEncode",
@@ -419,7 +364,7 @@
"id": 17,
"type": "ControlNetApplyAdvanced",
"pos": [
808,
804,
796
],
"size": {
@@ -448,7 +393,7 @@
{
"name": "image",
"type": "IMAGE",
"link": 20
"link": 25
}
],
"outputs": [
@@ -475,10 +420,65 @@
"Node name for S&R": "ControlNetApplyAdvanced"
},
"widgets_values": [
2.49999999999999,
1.6,
0,
1
]
},
{
"id": 19,
"type": "comfy-qr-by-module-size",
"pos": [
191,
629
],
"size": {
"0": 400,
"1": 284
},
"flags": {},
"order": 2,
"mode": 0,
"outputs": [
{
"name": "QR_CODE",
"type": "IMAGE",
"links": [
25
],
"shape": 3,
"slot_index": 0
},
{
"name": "QR_VERSION",
"type": "INT",
"links": null,
"shape": 3
},
{
"name": "IMAGE_SIZE",
"type": "INT",
"links": [
26,
27
],
"shape": 3,
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "comfy-qr-by-module-size"
},
"widgets_values": [
"Https",
"gitlab.com/sofuego-comfy-nodes",
16,
512,
"#000000",
"#FFFFFF",
"Medium",
1
]
}
],
"links": [
@@ -530,22 +530,6 @@
0,
"IMAGE"
],
[
14,
14,
2,
15,
0,
"INT"
],
[
15,
14,
2,
15,
1,
"INT"
],
[
16,
15,
@@ -570,14 +554,6 @@
2,
"CONTROL_NET"
],
[
20,
14,
0,
17,
3,
"IMAGE"
],
[
22,
17,
@@ -601,6 +577,30 @@
11,
1,
"CONDITIONING"
],
[
25,
19,
0,
17,
3,
"IMAGE"
],
[
26,
19,
2,
15,
0,
"INT"
],
[
27,
19,
2,
15,
1,
"INT"
]
],
"groups": [],
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+426
View File
@@ -0,0 +1,426 @@
{
"last_node_id": 11,
"last_link_id": 12,
"nodes": [
{
"id": 5,
"type": "MaskToImage",
"pos": [
420,
740
],
"size": {
"0": 210,
"1": 26
},
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "mask",
"type": "MASK",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
3
],
"shape": 3,
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "MaskToImage"
}
},
{
"id": 6,
"type": "ImageCompositeMasked",
"pos": [
640,
280
],
"size": {
"0": 315,
"1": 146
},
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "destination",
"type": "IMAGE",
"link": 3
},
{
"name": "source",
"type": "IMAGE",
"link": 12
},
{
"name": "mask",
"type": "MASK",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
8
],
"shape": 3,
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImageCompositeMasked"
},
"widgets_values": [
16,
16,
false
]
},
{
"id": 2,
"type": "LoadImage",
"pos": [
680,
520
],
"size": {
"0": 315,
"1": 314.0000305175781
},
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
9
],
"shape": 3,
"slot_index": 0
},
{
"name": "MASK",
"type": "MASK",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"unscannable_00001_.png",
"image"
]
},
{
"id": 9,
"type": "comfy-qr-mask_errors",
"pos": [
1030,
310
],
"size": {
"0": 317.4000244140625,
"1": 238
},
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "source_qr",
"type": "IMAGE",
"link": 8
},
{
"name": "modified_qr",
"type": "IMAGE",
"link": 9
}
],
"outputs": [
{
"name": "QR_ERROR_MASK",
"type": "MASK",
"links": [
10
],
"shape": 3,
"slot_index": 0
},
{
"name": "PERCENT_ERROR",
"type": "FLOAT",
"links": null,
"shape": 3
},
{
"name": "CORRELATION",
"type": "FLOAT",
"links": null,
"shape": 3
},
{
"name": "RMSE",
"type": "FLOAT",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "comfy-qr-mask_errors"
},
"widgets_values": [
16,
"mean",
"mean",
"module_pattern",
0,
false
]
},
{
"id": 8,
"type": "MaskToImage",
"pos": [
1399,
333
],
"size": {
"0": 210,
"1": 26
},
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "mask",
"type": "MASK",
"link": 10
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
11
],
"shape": 3,
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "MaskToImage"
}
},
{
"id": 10,
"type": "SaveImage",
"pos": [
1574,
474
],
"size": {
"0": 315,
"1": 270
},
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 11
}
],
"properties": {},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 4,
"type": "SolidMask",
"pos": [
38,
733
],
"size": {
"0": 315,
"1": 106
},
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MASK",
"type": "MASK",
"links": [
1
],
"shape": 3,
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "SolidMask"
},
"widgets_values": [
0.5,
768,
768
]
},
{
"id": 11,
"type": "comfy-qr-by-module-split",
"pos": [
111,
258
],
"size": {
"0": 400,
"1": 344
},
"flags": {},
"order": 2,
"mode": 0,
"outputs": [
{
"name": "QR_CODE",
"type": "IMAGE",
"links": [
12
],
"shape": 3,
"slot_index": 0
},
{
"name": "MODULE_LAYER",
"type": "IMAGE",
"links": null,
"shape": 3
},
{
"name": "FINDER_LAYER",
"type": "IMAGE",
"links": null,
"shape": 3
},
{
"name": "FINDER_MASK",
"type": "MASK",
"links": null,
"shape": 3
},
{
"name": "QR_VERSION",
"type": "INT",
"links": null,
"shape": 3
},
{
"name": "IMAGE_SIZE",
"type": "INT",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "comfy-qr-by-module-split"
},
"widgets_values": [
"Https",
"gitlab.com/sofuego-comfy-nodes/comfy-qr-validation-nodes",
16,
768,
"#000000",
"#FFFFFF",
"High",
1
]
}
],
"links": [
[
1,
4,
0,
5,
0,
"MASK"
],
[
3,
5,
0,
6,
0,
"IMAGE"
],
[
8,
6,
0,
9,
0,
"IMAGE"
],
[
9,
2,
0,
9,
1,
"IMAGE"
],
[
10,
9,
0,
8,
0,
"MASK"
],
[
11,
8,
0,
10,
0,
"IMAGE"
],
[
12,
11,
0,
6,
1,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}
+11 -11
View File
@@ -1,6 +1,6 @@
{
"last_node_id": 2,
"last_link_id": 1,
"last_node_id": 3,
"last_link_id": 2,
"nodes": [
{
"id": 2,
@@ -20,7 +20,7 @@
{
"name": "images",
"type": "IMAGE",
"link": 1
"link": 2
}
],
"properties": {},
@@ -29,11 +29,11 @@
]
},
{
"id": 1,
"id": 3,
"type": "comfy-qr-by-image-size",
"pos": [
429,
492
516,
479
],
"size": {
"0": 400,
@@ -44,10 +44,10 @@
"mode": 0,
"outputs": [
{
"name": "IMAGE",
"name": "QR_CODE",
"type": "IMAGE",
"links": [
1
2
],
"shape": 3,
"slot_index": 0
@@ -65,7 +65,7 @@
"widgets_values": [
"Https",
"gitlab.com/sofuego-comfy-nodes/comfy-qr",
768,
512,
"00F",
"FF0",
"High",
@@ -76,8 +76,8 @@
],
"links": [
[
1,
1,
2,
3,
0,
2,
0,

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+486 -183
View File
@@ -1,183 +1,486 @@
import numpy as np
import qrcode
from qrcode.compat.pil import Image
import torch
class QRBase:
def __init__(self):
self.text = ""
FUNCTION = "generate_qr"
CATEGORY = "Comfy-QR"
def _get_error_correction_constant(self, error_correction_string):
if error_correction_string == "Low":
return qrcode.constants.ERROR_CORRECT_L
if error_correction_string == "Medium":
return qrcode.constants.ERROR_CORRECT_M
if error_correction_string == "Quartile":
return qrcode.constants.ERROR_CORRECT_Q
return qrcode.constants.ERROR_CORRECT_H
def _img_to_tensor(self, img):
out_image = np.array(img, dtype=np.uint8).astype(np.float32) / 255
return torch.from_numpy(out_image).unsqueeze(0)
def _make_qr(self, qr, fill_hexcolor, back_hexcolor):
fill_color = self._parse_hexcolor_string(fill_hexcolor, "fill_hexcolor")
back_color = self._parse_hexcolor_string(back_hexcolor, "back_hexcolor")
qr.make(fit=True)
return qr.make_image(fill_color=fill_color, back_color=back_color)
def _parse_hexcolor_string(self, s, parameter):
if s.startswith("#"):
s = s[1:]
if len(s) == 3:
rgb = (c + c for c in s)
elif len(s) == 6:
rgb = (s[i] + s[i+1] for i in range(0, 6, 2))
else:
raise ValueError(f"{parameter} must be 3 or 6 characters long")
try:
return tuple(int(channel, 16) for channel in rgb)
except ValueError:
raise ValueError(f"{parameter} contains invalid hexadecimal characters")
def _validate_qr_size(self, size, max_size):
if size > max_size:
raise RuntimeError(f"QR dimensions of {size} exceed max size of {max_size}.")
def update_text(self, protocol, text):
"""This function takes input from a text box and a chosen internet
protocol and stores a full address within an instance variable.
Backslashes will invalidate text box input and this acts as a
workaround to be able to use them when required in QR strings.
Args:
protocol: A categorical variable of one of the available internet
protocols.
text: The input from the text box.
"""
if protocol == "Https":
prefix = "https://"
elif protocol == "Http":
prefix = "http://"
elif protocol == "None":
prefix = ""
self.text = prefix + text
class QRByImageSize(QRBase):
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"protocol": (["Http", "Https", "None"], {"default": "Https"}),
"text": ("STRING", {"multiline": True}),
"image_size": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 64}),
"fill_hexcolor": ("STRING", {"multiline": False, "default": "#000000"}),
"back_hexcolor": ("STRING", {"multiline": False, "default": "#FFFFFF"}),
"error_correction": (["Low", "Medium", "Quartile", "High"], {"default": "High"}),
"border": ("INT", {"default": 1, "min": 0, "max": 100, "step": 1}),
"resampling": (["Bicubic", "Bilinear", "Box", "Hamming", "Lanczos", "Nearest"], {"default": "Nearest"}),
},
}
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("IMAGE", "QR_VERSION")
def _select_resampling_method(self, resampling_string):
if resampling_string == "Nearest":
return Image.NEAREST
if resampling_string == "Bicubic":
return Image.BICUBIC
if resampling_string == "Bilinear":
return Image.BILINEAR
if resampling_string == "Lanczos":
return Image.LANCZOS
if resampling_string == "Box":
return Image.BOX
if resampling_string == "Hamming":
return Image.HAMMING
raise ValueError(f"Resampling method of {resampling_string} not supported")
def generate_qr(
self,
protocol,
text,
image_size,
fill_hexcolor,
back_hexcolor,
error_correction,
border,
resampling
):
resampling_method = self._select_resampling_method(resampling)
error_level = self._get_error_correction_constant(error_correction)
self.update_text(protocol, text)
qr = qrcode.QRCode(
error_correction=error_level,
box_size=1,
border=border)
qr.add_data(self.text)
img = self._make_qr(qr, fill_hexcolor, back_hexcolor)
self._validate_qr_size(img.pixel_size, image_size)
img = img.resize((image_size, image_size), resample=resampling_method)
return (self._img_to_tensor(img), qr.version)
class QRByModuleSize(QRBase):
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"protocol": (["Http", "Https", "None"], {"default": "Https"}),
"text": ("STRING", {"multiline": True}),
"module_size": ("INT", {"default": 16, "min": 1, "max": 64, "step": 1}),
"max_image_size": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 64}),
"fill_hexcolor": ("STRING", {"multiline": False, "default": "#000000"}),
"back_hexcolor": ("STRING", {"multiline": False, "default": "#FFFFFF"}),
"error_correction": (["Low", "Medium", "Quartile", "High"], {"default": "High"}),
"border": ("INT", {"default": 1, "min": 0, "max": 100, "step": 1}),
},
}
RETURN_TYPES = ("IMAGE", "INT", "INT")
RETURN_NAMES = ("IMAGE", "QR_VERSION", "IMAGE_SIZE")
def generate_qr(
self,
protocol,
text,
module_size,
max_image_size,
fill_hexcolor,
back_hexcolor,
error_correction,
border
):
self.update_text(protocol, text)
error_level = self._get_error_correction_constant(error_correction)
qr = qrcode.QRCode(
error_correction=error_level,
box_size=module_size,
border=border)
qr.add_data(self.text)
img = self._make_qr(qr, fill_hexcolor, back_hexcolor)
self._validate_qr_size(img.pixel_size, max_image_size)
return (self._img_to_tensor(img), qr.version, img.pixel_size)
NODE_CLASS_MAPPINGS = {
"comfy-qr-by-module-size": QRByModuleSize,
"comfy-qr-by-image-size": QRByImageSize,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"comfy-qr-by-module-size": "QR Code",
"comfy-qr-by-image-size": "QR Code (Conformed to Image Size)",
}
import numpy as np
import qrcode
from qrcode.compat.pil import Image
import torch
import torch.nn.functional as F
class QRBase:
def __init__(self):
self.text = ""
self.fill = None
self.back = None
FUNCTION = "generate_qr"
CATEGORY = "ComfyQR"
def _get_error_correction_constant(self, error_correction_string):
if error_correction_string == "Low":
return qrcode.constants.ERROR_CORRECT_L
if error_correction_string == "Medium":
return qrcode.constants.ERROR_CORRECT_M
if error_correction_string == "Quartile":
return qrcode.constants.ERROR_CORRECT_Q
return qrcode.constants.ERROR_CORRECT_H
def _img_to_tensor(self, img):
out_image = np.array(img, dtype=np.uint8).astype(np.float32) / 255
return torch.from_numpy(out_image).unsqueeze(0)
def _make_qr(self, qr, fill_hexcolor, back_hexcolor):
self.fill = self._parse_hexcolor_string(fill_hexcolor, "fill_hexcolor")
self.back = self._parse_hexcolor_string(back_hexcolor, "back_hexcolor")
qr.make(fit=True)
return qr.make_image(fill_color=self.fill, back_color=self.back)
def _parse_hexcolor_string(self, s, parameter):
if s.startswith("#"):
s = s[1:]
if len(s) == 3:
rgb = (c + c for c in s)
elif len(s) == 6:
rgb = (s[i] + s[i+1] for i in range(0, 6, 2))
else:
raise ValueError(f"{parameter} must be 3 or 6 characters long")
try:
return tuple(int(channel, 16) for channel in rgb)
except ValueError:
raise ValueError(f"{parameter} contains invalid hexadecimal characters")
def _validate_qr_size(self, size, max_size):
if size > max_size:
raise RuntimeError(f"QR dimensions of {size} exceed max size of {max_size}.")
def update_text(self, protocol, text):
"""This function takes input from a text box and a chosen internet
protocol and stores a full address within an instance variable.
Backslashes will invalidate text box input and this acts as a
workaround to be able to use them when required in QR strings.
Args:
protocol: A categorical variable of one of the available internet
protocols.
text: The input from the text box.
"""
if protocol == "Https":
prefix = "https://"
elif protocol == "Http":
prefix = "http://"
elif protocol == "None":
prefix = ""
self.text = prefix + text
class QRByImageSize(QRBase):
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"protocol": (["Http", "Https", "None"], {"default": "Https"}),
"text": ("STRING", {"multiline": True}),
"image_size": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 64}),
"fill_hexcolor": ("STRING", {"multiline": False, "default": "#000000"}),
"back_hexcolor": ("STRING", {"multiline": False, "default": "#FFFFFF"}),
"error_correction": (["Low", "Medium", "Quartile", "High"], {"default": "High"}),
"border": ("INT", {"default": 1, "min": 0, "max": 100, "step": 1}),
"resampling": (["Bicubic", "Bilinear", "Box", "Hamming", "Lanczos", "Nearest"], {"default": "Nearest"}),
},
}
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("QR_CODE", "QR_VERSION")
def _select_resampling_method(self, resampling_string):
if resampling_string == "Nearest":
return Image.NEAREST
if resampling_string == "Bicubic":
return Image.BICUBIC
if resampling_string == "Bilinear":
return Image.BILINEAR
if resampling_string == "Lanczos":
return Image.LANCZOS
if resampling_string == "Box":
return Image.BOX
if resampling_string == "Hamming":
return Image.HAMMING
raise ValueError(f"Resampling method of {resampling_string} not supported")
def generate_qr(
self,
protocol,
text,
image_size,
fill_hexcolor,
back_hexcolor,
error_correction,
border,
resampling
):
resampling_method = self._select_resampling_method(resampling)
error_level = self._get_error_correction_constant(error_correction)
self.update_text(protocol, text)
qr = qrcode.QRCode(
error_correction=error_level,
box_size=1,
border=border)
qr.add_data(self.text)
img = self._make_qr(qr, fill_hexcolor, back_hexcolor)
self._validate_qr_size(img.pixel_size, image_size)
img = img.resize((image_size, image_size), resample=resampling_method)
return (self._img_to_tensor(img), qr.version)
class QRByModuleSize(QRBase):
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"protocol": (["Http", "Https", "None"], {"default": "Https"}),
"text": ("STRING", {"multiline": True}),
"module_size": ("INT", {"default": 16, "min": 1, "max": 64, "step": 1}),
"max_image_size": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 64}),
"fill_hexcolor": ("STRING", {"multiline": False, "default": "#000000"}),
"back_hexcolor": ("STRING", {"multiline": False, "default": "#FFFFFF"}),
"error_correction": (["Low", "Medium", "Quartile", "High"], {"default": "High"}),
"border": ("INT", {"default": 1, "min": 0, "max": 100, "step": 1}),
},
}
RETURN_TYPES = ("IMAGE", "INT", "INT")
RETURN_NAMES = ("QR_CODE", "QR_VERSION", "IMAGE_SIZE")
def generate_qr(
self,
protocol,
text,
module_size,
max_image_size,
fill_hexcolor,
back_hexcolor,
error_correction,
border
):
self.update_text(protocol, text)
error_level = self._get_error_correction_constant(error_correction)
qr = qrcode.QRCode(
error_correction=error_level,
box_size=module_size,
border=border)
qr.add_data(self.text)
img = self._make_qr(qr, fill_hexcolor, back_hexcolor)
self._validate_qr_size(img.pixel_size, max_image_size)
return (self._img_to_tensor(img), qr.version, img.pixel_size)
class QRByModuleSizeSplitFunctionPatterns(QRBase):
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"protocol": (["Http", "Https", "None"], {"default": "Https"}),
"text": ("STRING", {"multiline": True}),
"module_size": ("INT", {"default": 16, "min": 1, "max": 64, "step": 1}),
"max_image_size": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 64}),
"fill_hexcolor": ("STRING", {"multiline": False, "default": "#000000"}),
"back_hexcolor": ("STRING", {"multiline": False, "default": "#FFFFFF"}),
"error_correction": (["Low", "Medium", "Quartile", "High"], {"default": "High"}),
"border": ("INT", {"default": 1, "min": 0, "max": 100, "step": 1}),
},
}
RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "MASK", "INT", "INT")
RETURN_NAMES = ("QR_CODE", "MODULE_LAYER", "FINDER_LAYER", "FINDER_MASK", "QR_VERSION", "IMAGE_SIZE")
def _generate_finder_pattern_ranges(self, module_size, border_size):
outer = module_size * border_size
inner = 7 * module_size + outer
# Alternate behavior is required to prevent bugs from 0 border_size.
far_outer = -outer if border_size else None
return [
(outer, inner, outer, inner),
(outer, inner, -inner, far_outer),
(-inner, far_outer, outer, inner)
]
def _generate_finder_pattern_mask(self, pixel_size, module_size, border_size):
mask = np.zeros((pixel_size, pixel_size), dtype=bool)
for x_min, x_max, y_min, y_max in self._generate_finder_pattern_ranges(module_size, border_size):
mask[y_min:y_max, x_min:x_max] = True
return mask
def _apply_fill_to_mask(self, img, mask):
array = np.array(img).copy()
indices = np.nonzero(mask)
array[indices[0], indices[1], :] = self.back
return Image.fromarray(array)
def _mask_to_tensor(self, mask):
out_image = mask.astype(np.float32)
return torch.from_numpy(out_image)
def generate_qr(
self,
protocol,
text,
module_size,
max_image_size,
fill_hexcolor,
back_hexcolor,
error_correction,
border
):
self.update_text(protocol, text)
error_level = self._get_error_correction_constant(error_correction)
qr = qrcode.QRCode(
error_correction=error_level,
box_size=module_size,
border=border)
qr.add_data(self.text)
img = self._make_qr(qr, fill_hexcolor, back_hexcolor)
pixel_size = img.pixel_size
self._validate_qr_size(pixel_size, max_image_size)
mask = self._generate_finder_pattern_mask(pixel_size, module_size, border)
module_image = self._apply_fill_to_mask(img, mask)
function_image = self._apply_fill_to_mask(img, ~mask)
return (
self._img_to_tensor(img),
self._img_to_tensor(module_image),
self._img_to_tensor(function_image),
self._mask_to_tensor(mask),
qr.version,
pixel_size,
)
class QRErrorMasker:
def __init__(self):
self.module_size = None
self.canvas_shape = None
self.qr_bounds = None
FUNCTION = "find_qr_errors"
CATEGORY = "ComfyQR"
RETURN_TYPES = ("MASK", "FLOAT", "FLOAT", "FLOAT")
RETURN_NAMES = ("QR_ERROR_MASK", "PERCENT_ERROR", "CORRELATION", "RMSE")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"source_qr": ("IMAGE",),
"modified_qr": ("IMAGE",),
"module_size": ("INT", {"default": 16, "min": 1, "max": 64, "step": 1}),
"grayscale_method": (["mean", "luminance"], {"default": "luminance"}),
"aggregate_method": (["mean",], {"default": "mean"}),
"evaluate": (["full_qr", "module_pattern", "finder_pattern"], {"default": "module_pattern"}),
"error_difficulty": ("FLOAT", {"default": 0, "min": 0, "max": 1, "step": .01}),
"inverted_pattern": ("BOOLEAN", {"default": False}),
"gamma": ("FLOAT", {"default": 2.2, "min": .1, "max": 2.8, "step": .1}),
},
}
def _get_qr_bounds(self, tensor, invert):
module_color = 1.0 if invert else 0.0
module_pixels = (tensor == module_color)
indices = torch.nonzero(module_pixels, as_tuple=True)
# The viewer patterns will guarentee a module pixel in the upper left
# The bottom right does not have that guarentee so max is used.
return indices[0][0], indices[0].max() + 1, indices[1][0], indices[1].max() + 1
def _extract_pattern_from_bounds(self, tensor):
return tensor[self.qr_bounds[0]:self.qr_bounds[1], self.qr_bounds[2]:self.qr_bounds[3]]
def _trim_to_qr_area(self, source_qr, modified_qr, inverted_pattern):
self.qr_bounds = self._get_qr_bounds(source_qr, inverted_pattern)
self._check_bounds_and_module_size()
source_qr = self._extract_pattern_from_bounds(source_qr)
modified_qr = self._extract_pattern_from_bounds(modified_qr)
return source_qr, modified_qr
def _reshape_tensor_to_modules(self, tensor):
if len(tensor.shape) != 2:
raise RuntimeError("Module reshaping requires a 2 dimensional array.")
length = tensor.shape[0] // self.module_size
reshaped_tensor = tensor.view(length, self.module_size, length, self.module_size)
rehaped_tensor = reshaped_tensor.permute(0, 2, 1, 3).contiguous()
return rehaped_tensor.view(length, length, self.module_size ** 2)
def _check_bounds_and_module_size(self):
height = self.qr_bounds[1] - self.qr_bounds[0]
width = self.qr_bounds[3] - self.qr_bounds[2]
color_warning = "Make sure that qr_fill and back colors have exact #FFFFFFF and #000000 values (and that module color values do not occur outside the QR) and invert is set correctly."
if width != height:
raise RuntimeError(f"Source QR dimensions are {width} x {height}. They must be a perfect square. {color_warning}")
if width % self.module_size:
raise RuntimeError(f"QR width of {width} does not fit module_size of {self.module_size}. It must be perfectly divisible. {color_warning}")
def _squeeze_by_mean(self, tensor):
return torch.mean(tensor, dim=-1)
def _gamma_expansion(self, tensor, gamma):
if gamma == 1:
return tensor
if gamma == 2.2:
return torch.where(tensor <= 0.04045, tensor / 12.92, ((tensor + 0.055) / 1.055) ** 2.4)
return tensor ** gamma
def _gamma_compression(self, tensor, gamma):
if gamma == 1:
return tensor
if gamma == 2.2:
return torch.where(tensor <= .0031308, tensor * 12.92, 1.055 * tensor ** (1/2.4) - 0.055)
return tensor ** (1/gamma)
def _grayscale_by_luminance(self, tensor, gamma):
weights = torch.tensor([0.2125, 0.7154, 0.0721], dtype=torch.float32)
tensor = self._gamma_expansion(tensor, gamma)
tensor = tensor @ weights
if gamma != 1:
tensor = tensor ** gamma
return self._gamma_compression(tensor, gamma)
def _squeeze_to_modules(self, tensor, method):
tensor = self._reshape_tensor_to_modules(tensor)
if method == "mean":
return self._squeeze_by_mean(tensor)
raise RuntimeError("Module aggregation currently only supports the mean.")
def _reduce_to_modules(
self,
source_qr,
modified_qr,
module_size,
grayscale_method,
aggregate_method,
inverted_pattern,
gamma
):
if source_qr.shape != modified_qr.shape:
raise ValueError("Source and modified QR must have the same dimensions.")
self.module_size = module_size
self.canvas_shape = (source_qr.shape[1], source_qr.shape[2])
# Ignore batch dimension
source_qr, modified_qr = source_qr[0], modified_qr[0]
# Processed first for simplified indexing of QR bounds.
source_qr = self._squeeze_by_mean(source_qr)
source_qr, modified_qr = self._trim_to_qr_area(source_qr,
modified_qr,
inverted_pattern
)
if grayscale_method == "mean":
modified_qr = self._squeeze_by_mean(modified_qr)
elif grayscale_method == "luminance":
modified_qr = self._grayscale_by_luminance(modified_qr, gamma)
else:
raise ValueError("Currently only mean is supported for rgb to grayscale conversion.")
source_qr = self._squeeze_to_modules(source_qr, "mean")
modified_qr = self._squeeze_to_modules(modified_qr, aggregate_method)
return source_qr, modified_qr
def _create_finder_pattern_mask(self, width, inverted):
mask = np.zeros((width, width), dtype=bool)
# When borders are trimmed and QR code has module size of 1, results
# are consistent.
finder_coords = [[0, 7, 0, 7], [0, 7, -7, None], [-7, None, 0, 7]]
for x_min, x_max, y_min, y_max in finder_coords:
mask[y_min:y_max, x_min:x_max] = True
return ~mask if inverted else mask
def _create_qr_mask(self, tensor, evaluate):
if evaluate == "module_pattern":
return self._create_finder_pattern_mask(tensor, True)
if evaluate == "finder_pattern":
return self._create_finder_pattern_mask(tensor, False)
return None
def _bin_tensor_to_threshold(self, tensor, contrast_difficulty):
tensor = tensor.clone()
threshold = contrast_difficulty / 2
# Since we are only interested in value matches and there is a clear
# stable dividing line of .5, bringing in the other array is
# unneccessary and the binning process can be simplified.
bin_condition = (tensor + threshold <= .5) & (tensor != .5)
tensor[bin_condition] = 0.0
bin_condition = (tensor - threshold >= .5) & (tensor != .5)
tensor[bin_condition] = 1.0
return tensor
def _replace_qr_to_canvas(self, tensor):
length = tensor.shape[0] * self.module_size
bounds = self.qr_bounds
tensor = F.interpolate(tensor.unsqueeze(0).unsqueeze(0), size=(length, length), mode='nearest')
canvas = torch.zeros(self.canvas_shape, dtype=torch.float32)
canvas[bounds[0]:bounds[1], bounds[2]:bounds[3]] = tensor.squeeze()
return canvas
def _compare_modules(
self,
source_qr,
modified_qr,
mask,
error_difficulty
):
modified_qr = self._bin_tensor_to_threshold(modified_qr, error_difficulty)
error = source_qr != modified_qr
percent_error = error[mask].sum().item() / error[mask].numel()
if mask is not None:
error[~mask] = False
return self._replace_qr_to_canvas((error).to(torch.float32)), percent_error
def _qr_correlation(self, source_qr, modified_qr, mask):
source_qr = source_qr[mask].numpy().reshape((-1))
modified_qr = modified_qr[mask].numpy().reshape((-1))
return np.corrcoef(source_qr, modified_qr)[0, 1]
def _qr_rmse(self, source_qr, modified_qr, mask):
diff = source_qr[mask].numpy() - modified_qr[mask].numpy()
return np.sqrt((diff ** 2).mean())
def find_qr_errors(
self,
source_qr,
modified_qr,
module_size,
grayscale_method,
aggregate_method,
evaluate,
error_difficulty,
inverted_pattern,
gamma,
):
source_qr, modified_qr = self._reduce_to_modules(source_qr,
modified_qr,
module_size,
grayscale_method,
aggregate_method,
inverted_pattern,
gamma
)
mask = self._create_qr_mask(source_qr.shape[0], evaluate)
error_mask, percent_error = self._compare_modules(source_qr,
modified_qr,
mask,
error_difficulty
)
correlation = self._qr_correlation(source_qr, modified_qr, mask)
rmse = self._qr_rmse(source_qr, modified_qr, mask)
return (error_mask, percent_error, correlation, rmse)
NODE_CLASS_MAPPINGS = {
"comfy-qr-by-module-size": QRByModuleSize,
"comfy-qr-by-image-size": QRByImageSize,
"comfy-qr-by-module-split": QRByModuleSizeSplitFunctionPatterns,
"comfy-qr-mask_errors": QRErrorMasker,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"comfy-qr-by-module-size": "QR Code",
"comfy-qr-by-image-size": "QR Code (Conformed to Image Size)",
"comfy-qr-by-module-split": "QR Code (Split)",
"comfy-qr-mask_errors": "Mask QR Errors",
}