demo-weighted-kmeans.py
```python
import cv2
import numpy as np
import torch

from PIL import Image
from pytorch_msssim import ssim as _ssim

from pixeloe.pixelize import pixelize


def psnr(img1, img2):
    mse = np.mean((img1 - img2) ** 2)
    if mse == 0:
        return 100
    PIXEL_MAX = 255.0
    return 20 * np.log10(PIXEL_MAX / np.sqrt(mse))


def ssim(img1, img2):
    img1 = torch.from_numpy(img1).float().permute(2, 0, 1).unsqueeze(0)
    img2 = torch.from_numpy(img2).float().permute(2, 0, 1).unsqueeze(0)
    return _ssim(img1, img2)


if __name__ == "__main__":
    size = 256
    thickness = 2
    patch_size = 6
    pixel_size = 4
    img = Image.open("img/fox-girl.png")
    img = np.array(img)
    img_arr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)

    for size in [256]:
        for colors in [32, 48, 64]:
            img_weighted = pixelize(
                img_arr,
                target_size=size,
                thickness=thickness,
                patch_size=patch_size,
                colors=colors,
                colors_with_weight=True,
            )
            img_weighted = cv2.cvtColor(img_weighted, cv2.COLOR_BGR2RGB)
            img_normal = pixelize(
                img_arr,
                target_size=size,
                thickness=thickness,
                patch_size=patch_size,
                colors=colors,
                colors_with_weight=False,
            )
            img_normal = cv2.cvtColor(img_normal, cv2.COLOR_BGR2RGB)
            img_ref = pixelize(
                img_arr,
                target_size=size,
                thickness=thickness,
                patch_size=patch_size,
            )
            img_ref = cv2.cvtColor(img_ref, cv2.COLOR_BGR2RGB)

            psnr_weighted = psnr(np.array(img_weighted), np.array(img_ref))
            psnr_normal = psnr(np.array(img_normal), np.array(img_ref))
            print(
                f"colors: {colors:03}, size: {size:04}, "
                f"psnr_weighted: {psnr_weighted:5.2f}, psnr_normal: {psnr_normal:5.2f}"
            )
            ssim_weighted = ssim(np.array(img_weighted), np.array(img_ref))
            ssim_normal = ssim(np.array(img_normal), np.array(img_ref))
            print(
                f"colors: {colors:03}, size: {size:04}, "
                f"ssim_weighted: {ssim_weighted:5.3f}, ssim_normal: {ssim_normal:5.3f}"
            )

            img_weighted = Image.fromarray(img_weighted)
            img_normal = Image.fromarray(img_normal)
            img_ref = Image.fromarray(img_ref)
            grid = Image.new("RGB", (img_weighted.width * 3, img_weighted.height))
            grid.paste(img_ref, (0, 0))
            grid.paste(img_weighted, (img_ref.width, 0))
            grid.paste(img_normal, (img_ref.width * 2, 0))
            grid.save(f"demo/weighted_kmeans/{size}_{colors}.png")

```

demo.py
```python
import os
from subprocess import Popen
from time import time

all_command = []

for file, size, p, t in [
    ("./img/dragon-girl.png", 256, 6, 1),
    ("./img/house.png", 128, 8, 2),
    ("./img/horse-girl.png", 256, 8, 2),
]:
    output_file = os.path.join("./demo", os.path.splitext(os.path.basename(file))[0])
    for thickness in [0, t]:
        for mode in ["nearest", "bicubic", "center", "k-centroid", "contrast"]:
            all_command.append(
                "python -m pixeloe.cli "
                f"{file} --thickness {thickness} "
                f"--target_size {size} --patch_size {p} "
                f"-M {mode} -O {output_file}-t{thickness}-{mode}.png"
            )


## Use Popen to run all commands in parallel
t0 = time()
all_process = []
for command in all_command:
    print(command)
    all_process.append(Popen(command, shell=True))


## Wait for all processes to finish
for process in all_process:
    process.wait()
t1 = time()
print("=" * 50)
print(f"Total time   : {t1 - t0:.3f}sec")
print(f"Total process: {len(all_process)}")
print(f"Average cost : {(t1 - t0) / len(all_process):.3f}sec")

```

setup.py
```python
from setuptools import setup, find_packages


command_map = {
    "pixelize": "pixelize",
    "outline": "outline",
}


setup(
    name="pixeloe",
    packages=find_packages(),
    version="0.0.10",
    url="https://github.com/KohakuBlueleaf/PixelOE",
    description="Detail-Oriented Pixelization based on Contrast-Aware Outline Expansion.",
    license="Apache License 2.0",
    author="Shih-Ying Yeh(KohakuBlueLeaf)",
    author_email="apolloyeh0123@gmail.com",
    zip_safe=False,
    install_requires=["opencv-python", "numpy", "pillow"],
    entry_points={
        "console_scripts": [
            f"pixeloe.{command}=pixeloe.cli:{func}"
            for command, func in command_map.items()
        ]
    },
    python_requires=">=3.10",
)

```

pixeloe\cli.py
```python
import os
from argparse import ArgumentParser


def parse_args_pixelize():
    parser = ArgumentParser()
    parser.add_argument("input_img", type=str)
    parser.add_argument("--output_img", "-O", type=str, default=None)
    parser.add_argument(
        "--mode",
        "-M",
        type=str,
        default="contrast",
        choices=["center", "contrast", "k-centroid", "bicubic", "nearest"],
    )
    parser.add_argument("--target_size", "-S", type=int, nargs="+", default=256)
    parser.add_argument("--patch_size", "-P", type=int, default=6)
    parser.add_argument("--thickness", "-T", type=int, default=1)
    parser.add_argument("--no_color_matching", action="store_true")
    parser.add_argument("--contrast", type=float, default=1.0)
    parser.add_argument("--saturation", type=float, default=1.0)
    parser.add_argument("--colors", type=int, default=None)
    parser.add_argument("--no_upscale", action="store_true")
    parser.add_argument("--no_downscale", action="store_true")
    return parser.parse_args()


def pixelize():
    args = parse_args_pixelize()
    import numpy as np
    import cv2
    from PIL import Image

    from .pixelize import pixelize

    img = Image.open(args.input_img)
    img = np.array(img)
    img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
    img = pixelize(
        img,
        args.mode,
        args.target_size,
        args.patch_size,
        args.thickness,
        not args.no_color_matching,
        args.contrast,
        args.saturation,
        args.colors,
        args.no_upscale,
        args.no_downscale,
    )
    if args.output_img is None:
        args.output_img = os.path.join(
            os.path.dirname(args.input_img),
            f"output_{os.path.basename(args.input_img)}",
        )
        print(f"Output image: {args.output_img}")
    data = cv2.imencode(os.path.splitext(args.output_img)[1], img)[1].tobytes()
    with open(args.output_img, "wb") as f:
        f.write(data)


def parse_args_outline_expansion():
    parser = ArgumentParser()
    parser.add_argument("input_img", type=str)
    parser.add_argument("--output_img", "-O", type=str, default=None)
    parser.add_argument("--target_size", "-S", type=int, default=256)
    parser.add_argument("--patch_size", "-P", type=int, default=6)
    parser.add_argument("--thickness", "-T", type=int, default=1)
    parser.add_argument("--no_color_matching", action="store_true")
    return parser.parse_args()


def outline():
    args = parse_args_outline_expansion()
    import numpy as np
    import cv2
    from PIL import Image

    from .outline import outline_expansion
    from .color import match_color

    img = Image.open(args.input_img)
    H, W = img.height, img.width
    target_size = args.target_size
    patch_size = args.patch_size

    ratio = W / H
    target_org_size = (target_size**2 * patch_size**2 / ratio) ** 0.5
    target_org_hw = (int(target_org_size * ratio), int(target_org_size))
    img = img.resize(target_org_hw)
    img = np.array(img)
    img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
    img_out = outline_expansion(
        img,
        args.thickness,
        args.thickness,
        patch_size,
    )
    if not args.no_color_matching:
        img_out = match_color(img_out, img)
    img_out = cv2.cvtColor(img_out, cv2.COLOR_BGR2RGB)
    img_out = Image.fromarray(img_out)
    if args.output_img is None:
        args.output_img = os.path.join(
            os.path.dirname(args.input_img),
            f"output_{os.path.basename(args.input_img)}",
        )
    img_out.save(args.output_img)


command_map = {
    "pixelize": "pixelize",
    "outline": "outline",
}

```

pixeloe\color.py
```python
import cv2
import numpy as np
from PIL import Image


def match_color(source, target, level=5):
    # Convert RGB to L*a*b*, and then match the std/mean
    source_lab = cv2.cvtColor(source, cv2.COLOR_BGR2LAB).astype(np.float32) / 255
    target_lab = cv2.cvtColor(target, cv2.COLOR_BGR2LAB).astype(np.float32) / 255
    result = (source_lab - np.mean(source_lab)) / np.std(source_lab)
    result = result * np.std(target_lab) + np.mean(target_lab)
    source = cv2.cvtColor(
        (result * 255).clip(0, 255).astype(np.uint8), cv2.COLOR_LAB2BGR
    )

    source = source.astype(np.float32)
    # Use wavelet colorfix method to match original low frequency data at first
    source[:, :, 0] = wavelet_colorfix(source[:, :, 0], target[:, :, 0], level=level)
    source[:, :, 1] = wavelet_colorfix(source[:, :, 1], target[:, :, 1], level=level)
    source[:, :, 2] = wavelet_colorfix(source[:, :, 2], target[:, :, 2], level=level)
    output = source
    return output.clip(0, 255).astype(np.uint8)


def wavelet_colorfix(inp, target, level=5):
    inp_high, _ = wavelet_decomposition(inp, level)
    _, target_low = wavelet_decomposition(target, level)
    output = inp_high + target_low
    return output


def wavelet_decomposition(inp, levels):
    high_freq = np.zeros_like(inp)
    for i in range(1, levels + 1):
        radius = 2**i
        low_freq = wavelet_blur(inp, radius)
        high_freq = high_freq + (inp - low_freq)
        inp = low_freq
    return high_freq, low_freq


def wavelet_blur(inp, radius):
    kernel_size = 2 * radius + 1
    output = cv2.GaussianBlur(inp, (kernel_size, kernel_size), 0)
    return output


def color_styling(inp, saturation=1.2, contrast=1.1):
    output = inp.copy()
    output = cv2.cvtColor(output, cv2.COLOR_BGR2HSV)
    output[:, :, 1] = output[:, :, 1] * saturation
    output[:, :, 2] = output[:, :, 2] * contrast - (contrast - 1)
    output = np.clip(output, 0, 1)
    output = cv2.cvtColor(output, cv2.COLOR_HSV2BGR)
    return output


def color_quant(image, colors=32, weights=None, repeats=64, method="kmeans"):
    # TODO: more consistent/better color quant method
    #       (K-means is not good enough)
    match method:
        case "kmeans":
            if weights is not None:
                h, w, c = image.shape
                pixels = []
                weights = weights / np.max(weights) * repeats
                for i in range(h):
                    for j in range(w):
                        repeat_times = max(1, int(weights[i, j]))
                        pixels.extend([image[i, j]] * repeat_times)
                pixels = np.array(pixels, dtype=np.float32)
                criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 32, 1)
                _, labels, palette = cv2.kmeans(
                    pixels, colors, None, criteria, 4, cv2.KMEANS_RANDOM_CENTERS
                )

                quantized_image = np.zeros((h, w, c), dtype=np.uint8)
                label_idx = 0
                for i in range(h):
                    for j in range(w):
                        repeat_times = max(1, int(weights[i, j]))
                        quantized_image[i, j] = palette[labels[label_idx]]
                        label_idx += repeat_times
                return quantized_image
            else:
                h, w, c = image.shape
                pixels = image.reshape((-1, 3)).astype(np.float32)
                criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 32, 1)
                _, labels, palette = cv2.kmeans(
                    pixels, colors, None, criteria, 4, cv2.KMEANS_RANDOM_CENTERS
                )

                quantized_image = np.zeros((h, w, c), dtype=np.uint8)
                for i in range(h):
                    for j in range(w):
                        quantized_image[i, j] = palette[labels[i * w + j]]
                return quantized_image
        case "maxcover":
            img = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
            img_pil = Image.fromarray(img)
            img_quant = img_pil.quantize(colors, 1, kmeans=colors).convert("RGB")
            return cv2.cvtColor(np.array(img_quant), cv2.COLOR_RGB2BGR)

```

pixeloe\outline.py
```python
from functools import partial

import cv2
import numpy as np

from .utils import sigmoid, apply_chunk


def expansion_weight(img, k=8, stride=2, avg_scale=10, dist_scale=3):
    img_y = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)[:, :, 0] / 255
    avg_y = apply_chunk(img_y, k * 2, stride, partial(np.median, axis=1, keepdims=True))
    max_y = apply_chunk(img_y, k, stride, partial(np.max, axis=1, keepdims=True))
    min_y = apply_chunk(img_y, k, stride, partial(np.min, axis=1, keepdims=True))
    bright_dist = max_y - avg_y
    dark_dist = avg_y - min_y

    weight = (avg_y - 0.5) * avg_scale
    weight = weight - (bright_dist - dark_dist) * dist_scale

    output = sigmoid(weight)
    output = cv2.resize(
        output,
        (img.shape[1] // stride, img.shape[0] // stride),
        interpolation=cv2.INTER_LINEAR,
    )
    output = cv2.resize(
        output, (img.shape[1], img.shape[0]), interpolation=cv2.INTER_LINEAR
    )

    return (output - np.min(output)) / (np.max(output))


kernel_expansion = np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]]).astype(np.uint8)
kernel_smoothing = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]]).astype(np.uint8)


def outline_expansion(img, erode=2, dilate=2, k=16, avg_scale=10, dist_scale=3):
    weight = expansion_weight(img, k, (k // 4) * 2, avg_scale, dist_scale)[
        ..., np.newaxis
    ]
    orig_weight = sigmoid((weight - 0.5) * 5) * 0.25

    img_erode = img.copy()
    img_erode = cv2.erode(img_erode, kernel_expansion, iterations=erode).astype(
        np.float32
    )
    img_dilate = img.copy()
    img_dilate = cv2.dilate(img_dilate, kernel_expansion, iterations=dilate).astype(
        np.float32
    )

    output = img_erode * weight + img_dilate * (1 - weight)
    output = output * (1 - orig_weight) + img.astype(np.float32) * orig_weight
    output = output.astype(np.uint8).copy()

    output = cv2.erode(output, kernel_smoothing, iterations=erode)
    output = cv2.dilate(output, kernel_smoothing, iterations=dilate * 2)
    output = cv2.erode(output, kernel_smoothing, iterations=erode)

    weight = np.abs(weight * 2 - 1) * 255
    weight = cv2.dilate(weight.astype(np.uint8), kernel_expansion, iterations=dilate)
    return output, weight.astype(np.float32) / 255


if __name__ == "__main__":
    img = cv2.imread("img/dragon-girl.png")
    H, W, _ = img.shape
    ratio = W / H
    target_pixel_count = (1024**2 / ratio) ** 0.5
    img = cv2.resize(img, (int(target_pixel_count * ratio), int(target_pixel_count)))
    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
    weight_mat = expansion_weight(img, 8, 2, 9, 3)
    img_out = outline_expansion(img, 1, 1)

    edge = img_out - img
    edge = (edge + 255) / 2
    edge[np.abs(edge - 127) >= 30] = 0
    edge[np.abs(edge - 127) < 30] = 255
    edge = cv2.cvtColor(edge.astype(np.uint8), cv2.COLOR_BGR2GRAY) / 255

    # show the weight mat and output in matplotlib in same window
    import matplotlib.pyplot as plt

    plt.subplot(1, 4, 1)
    plt.imshow(img)
    plt.axis("off")
    plt.title("input")
    plt.subplot(1, 4, 2)
    plt.imshow(img_out)
    plt.axis("off")
    plt.title("output")
    plt.subplot(1, 4, 3)
    plt.imshow(weight_mat, cmap="gray")
    plt.axis("off")
    plt.title("weight")
    plt.subplot(1, 4, 4)
    plt.imshow(edge, cmap="gray")
    plt.axis("off")
    plt.title("edge")
    plt.show()

```

pixeloe\pixelize.py
```python
from time import time

import cv2
import numpy as np

from .color import match_color, color_styling, color_quant
from .downscale import downscale_mode
from .outline import outline_expansion, expansion_weight
from .utils import isiterable


def pixelize(
    img,
    mode="contrast",
    target_size=128,
    patch_size=16,
    pixel_size=None,
    thickness=2,
    color_matching=True,
    contrast=1.0,
    saturation=1.0,
    colors=None,
    color_quant_method="kmeans",
    colors_with_weight=False,
    no_upscale=False,
    no_downscale=False,
):
    H, W, _ = img.shape
    if pixel_size is None:
        pixel_size = patch_size

    ratio = W / H
    if isiterable(target_size) and len(target_size) > 1:
        target_org_hw = tuple([int(i * patch_size) for i in target_size][:2])
        ratio = target_org_hw[0] / target_org_hw[1]
        target_org_size = target_org_hw[1]
        target_size = ((target_org_size**2) / (patch_size**2) * ratio) ** 0.5
    else:
        if isiterable(target_size):
            target_size = target_size[0]
        target_org_size = (target_size**2 * patch_size**2 / ratio) ** 0.5
        target_org_hw = (int(target_org_size * ratio), int(target_org_size))

    img = cv2.resize(img, target_org_hw)
    org_img = img.copy()

    if thickness:
        img, weight = outline_expansion(img, thickness, thickness, patch_size, 9, 4)
    elif colors is not None and colors_with_weight:
        weight = expansion_weight(img, patch_size, (patch_size // 4) * 2, 9, 4)[
            ..., np.newaxis
        ]
        weight = np.abs(weight * 2 - 1)

    if color_matching:
        img = match_color(img, org_img)

    if no_downscale:
        return img
    img_sm = downscale_mode[mode](img, target_size)

    if colors is not None:
        img_sm_orig = img_sm.copy()
        weight_mat = None
        if colors_with_weight:
            weight_mat = cv2.resize(
                weight,
                (img_sm.shape[1], img_sm.shape[0]),
                interpolation=cv2.INTER_LINEAR,
            )
            # TODO: How to get more reasonable weight?
            weight_gamma = target_size / 512
            weight_mat = weight_mat**weight_gamma
        img_sm = color_quant(
            img_sm,
            colors,
            weight_mat,
            # TODO: How to get more reasonable repeat times?
            int((patch_size * colors) ** 0.5),
            color_quant_method,
        )
        img_sm = match_color(img_sm, img_sm_orig, 3)

    if contrast != 1 or saturation != 1:
        img_sm = color_styling(img_sm, saturation, contrast)

    if no_upscale:
        return img_sm

    return cv2.resize(
        img_sm,
        (img_sm.shape[1] * pixel_size, img_sm.shape[0] * pixel_size),
        interpolation=cv2.INTER_NEAREST,
    )


if __name__ == "__main__":
    t0 = time()
    img = cv2.imread("img/house.png")
    t1 = time()
    img = pixelize(img, target_size=128, patch_size=8)
    t2 = time()
    cv2.imwrite("test.png", img)
    t3 = time()

    print(f"read time: {t1 - t0:.3f}sec")
    print(f"pixelize time: {t2 - t1:.3f}sec")
    print(f"write time: {t3 - t2:.3f}sec")

```

pixeloe\utils.py
```python
import numpy as np
import torch
import torch.nn.functional as F


@torch.no_grad()
def apply_chunk(data, kernel, stride, func):
    org_shape = data.shape
    unfold_shape = org_shape

    k_shift = max(kernel - stride, 0)
    pad_pattern = (k_shift // 2, k_shift // 2 + k_shift % 2)
    data = np.pad(data, (pad_pattern, pad_pattern), "edge")

    if len(org_shape) == 2:
        data = data[np.newaxis, np.newaxis, ...]

    data = (
        F.unfold(torch.tensor(data), kernel, 1, 0, stride).transpose(-1, -2)[0].numpy()
    )
    data[..., : stride**2] = func(data)
    data = data[np.newaxis, ..., : stride**2]
    data = F.fold(
        torch.tensor(data).transpose(-1, -2),
        unfold_shape,
        stride,
        1,
        0,
        stride,
    )[0].numpy()

    if len(org_shape) < 3:
        data = data[0]
    return data


def sigmoid(x):
    return 1 / (1 + np.exp(-x))


def isiterable(x):
    return hasattr(x, "__iter__")


if __name__ == "__main__":
    data = np.zeros((504, 504))
    output = apply_chunk(data, 9, 4, lambda x: x[..., 0:1])
    print(output.shape, data.shape)

```

pixeloe\__init__.py
```python

```

pixeloe\downscale\center.py
```python
from functools import partial

import numpy as np
import cv2

from ..utils import apply_chunk


def center_downscale(
    img,
    target_size=128,
):
    H, W, _ = img.shape

    ratio = W / H
    target_size = (target_size**2 / ratio) ** 0.5
    target_hw = (int(target_size * ratio), int(target_size))
    patch_size = max(int(round(H // target_hw[1])), int(round(W // target_hw[0])))

    img_lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB).astype(np.float32)
    img_lab[:, :, 0] = apply_chunk(
        img_lab[:, :, 0],
        patch_size,
        patch_size,
        lambda x: x[..., x.shape[-1] // 2][..., None],
    )
    img_lab[:, :, 1] = apply_chunk(
        img_lab[:, :, 1],
        patch_size,
        patch_size,
        partial(np.median, axis=1, keepdims=True),
    )
    img_lab[:, :, 2] = apply_chunk(
        img_lab[:, :, 2],
        patch_size,
        patch_size,
        partial(np.median, axis=1, keepdims=True),
    )
    img = cv2.cvtColor(img_lab.clip(0, 255).astype(np.uint8), cv2.COLOR_LAB2BGR)

    img_sm = cv2.resize(img, target_hw, interpolation=cv2.INTER_NEAREST)
    return img_sm

```

pixeloe\downscale\contrast_based.py
```python
from functools import partial

import numpy as np
import cv2

from ..utils import apply_chunk


def find_pixel(chunks):
    mid = chunks[..., chunks.shape[-1] // 2][..., np.newaxis]
    med = np.median(chunks, axis=1, keepdims=True)
    mu = np.mean(chunks, axis=1, keepdims=True)
    maxi = np.max(chunks, axis=1, keepdims=True)
    mini = np.min(chunks, axis=1, keepdims=True)

    output = mid
    mini_loc = (med < mu) & (maxi - med > med - mini)
    maxi_loc = (med > mu) & (maxi - med < med - mini)

    output[mini_loc] = mini[mini_loc]
    output[maxi_loc] = maxi[maxi_loc]

    return output


def contrast_based_downscale(
    img,
    target_size=128,
):
    H, W, _ = img.shape

    ratio = W / H
    target_size = (target_size**2 / ratio) ** 0.5
    target_hw = (int(target_size * ratio), int(target_size))
    patch_size = max(int(round(H // target_hw[1])), int(round(W // target_hw[0])))

    img_lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB).astype(np.float32)
    img_lab[:, :, 0] = apply_chunk(img_lab[:, :, 0], patch_size, patch_size, find_pixel)
    img_lab[:, :, 1] = apply_chunk(
        img_lab[:, :, 1],
        patch_size,
        patch_size,
        partial(np.median, axis=1, keepdims=True),
    )
    img_lab[:, :, 2] = apply_chunk(
        img_lab[:, :, 2],
        patch_size,
        patch_size,
        partial(np.median, axis=1, keepdims=True),
    )
    img = cv2.cvtColor(img_lab.clip(0, 255).astype(np.uint8), cv2.COLOR_LAB2BGR)

    img_sm = cv2.resize(img, target_hw, interpolation=cv2.INTER_NEAREST)
    return img_sm

```

pixeloe\downscale\conventional.py
```python
import cv2
import numpy as np
from PIL import Image


def nearest(
    img,
    target_size=128,
):
    H, W, _ = img.shape

    ratio = W / H
    target_size = (target_size**2 / ratio) ** 0.5
    target_hw = (int(target_size * ratio), int(target_size))
    img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
    img = Image.fromarray(img_rgb)
    img_sm = img.resize(target_hw, Image.NEAREST)
    img_sm = cv2.cvtColor(np.asarray(img_sm), cv2.COLOR_RGB2BGR)
    return img_sm


def bicubic(
    img,
    target_size=128,
):
    H, W, _ = img.shape

    ratio = W / H
    target_size = (target_size**2 / ratio) ** 0.5
    target_hw = (int(target_size * ratio), int(target_size))
    img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
    img = Image.fromarray(img_rgb)
    img_sm = img.resize(target_hw, Image.BICUBIC)
    img_sm = cv2.cvtColor(np.asarray(img_sm), cv2.COLOR_RGB2BGR)
    return img_sm

```

pixeloe\downscale\k_centroid.py
```python
import math
from itertools import product

import cv2
import numpy as np
from PIL import Image


def k_centroid_downscale(cv2img, target_size=128, centroids=2):
    """
    k-centroid downscaling algorithm from Astropulse, under MIT License.
    https://github.com/Astropulse/pixeldetector/blob/6e88e18ddbd16529b5dd85b1c615cbb2e5778bf2/k-centroid.py#L19-L44
    """
    H, W, _ = cv2img.shape

    ratio = W / H
    target_size = (target_size**2 / ratio) ** 0.5
    height = int(target_size)
    width = int(target_size * ratio)

    # Perform outline expansion and color matching
    image = Image.fromarray(cv2.cvtColor(cv2img, cv2.COLOR_BGR2RGB)).convert("RGB")

    # Downscale outline expanded image with k-centroid
    # Create an empty array for the downscaled image
    downscaled = np.zeros((height, width, 3), dtype=np.uint8)

    # Calculate the scaling factors
    wFactor = image.width / width
    hFactor = image.height / height

    # Iterate over each tile in the downscaled image
    for x, y in product(range(width), range(height)):
        # Crop the tile from the original image
        tile = image.crop(
            (x * wFactor, y * hFactor, (x * wFactor) + wFactor, (y * hFactor) + hFactor)
        )

        # Quantize the colors of the tile using k-means clustering
        tile = tile.quantize(colors=centroids, method=1, kmeans=centroids).convert(
            "RGB"
        )

        # Get the color counts and find the most common color
        color_counts = tile.getcolors()
        most_common_color = max(color_counts, key=lambda x: x[0])[1]

        # Assign the most common color to the corresponding pixel in the downscaled image
        downscaled[y, x, :] = most_common_color

    return cv2.cvtColor(downscaled, cv2.COLOR_RGB2BGR)

```

pixeloe\downscale\__init__.py
```python
from .contrast_based import contrast_based_downscale
from .conventional import bicubic, nearest
from .center import center_downscale
from .k_centroid import k_centroid_downscale


downscale_mode = {
    "bicubic": bicubic,
    "nearest": nearest,
    "center": center_downscale,
    "contrast": contrast_based_downscale,
    "k-centroid": k_centroid_downscale,
}

```

这是一个用python开发的把图片转成像素画的程序，上面包含了整个项目的所有代码。请你把它移植到JavaScript+HTML，使其能在浏览器里运行。网页设计成左右并排展示图像，左边上传原始图片，右边查看转换结果。下面有一个调整像素大小的滑条，一个转换按钮，一个保存按钮。以及其他参数的配置界面。

请你先梳理一下程序的算法，然后按要求移植一份。因为项目使用了第三方库，你需要考虑如何在JavaScript里实现它。如果有现成的库能用尽量使用，如果没有，则需要实现具体的逻辑。
你最终需要给出一份完整的，生产可用的高质量代码。确保实现准确无误，功能完整。