image tiling in loops using Python OpenCV - python

Python noob needs some help guys! Can someone show me how to rewrite my code using loops? Tried some different syntaxes but did not seem to work!
img = cv2.imread("C://Users//user//Desktop//research//images//Underwater_Caustics//set1//set1_color_0001.png")
tile11=img[1:640, 1:360]
cv2.imwrite('tile11_underwater_caustic_set1_0001.png', tile11)
tile12=img[641:1280, 1:360]
cv2.imwrite('tile12_underwater_caustic_set1_0001.png', tile12)
tile13=img[1281:1920, 1:360]
cv2.imwrite('tile13_underwater_caustic_set1_0001.png', tile13)
tile21=img[1:640, 361:720]
cv2.imwrite('tile21_underwater_caustic_set1_0001.png', tile21)
tile22=img[641:1280, 361:720]
cv2.imwrite('tile22_underwater_caustic_set1_0001.png', tile22)
tile23=img[1281:1920, 361:720]
cv2.imwrite('tile23_underwater_caustic_set1_0001.png', tile23)
tile31=img[1:640, 721:1080]
cv2.imwrite('tile31_underwater_caustic_set1_0001.png', tile31)
tile32=img[641:1280, 721:1080]
cv2.imwrite('tile32_underwater_caustic_set1_0001.png', tile32)
tile33=img[1281:1920, 721:1080]
cv2.imwrite('tile33_underwater_caustic_set1_0001.png', tile33)
As you can see, the image will be cut into 9 equal-size pieces, how to write it using loops?

This won't produce the same result like your code, but will give you some ideas:
img = cv2.imread('sample.jpg')
numrows, numcols = 4, 4
height = int(img.shape[0] / numrows)
width = int(img.shape[1] / numcols)
for row in range(numrows):
for col in range(numcols):
y0 = row * height
y1 = y0 + height
x0 = col * width
x1 = x0 + width
cv2.imwrite('tile_%d%d.jpg' % (row, col), img[y0:y1, x0:x1])

I needed image tiling where last parts or edge tiles are required to be full tile images.
Here is the code I use:
import cv2
import math
import os
Path = "FullImage.tif";
filename, file_extension = os.path.splitext(Path)
image = cv2.imread(Path, 0)
tileSizeX = 256;
tileSizeY = 256;
numTilesX = math.ceil(image.shape[1]/tileSizeX)
numTilesY = math.ceil(image.shape[0]/tileSizeY)
makeLastPartFull = True; # in case you need even siez
for nTileX in range(numTilesX):
for nTileY in range(numTilesY):
startX = nTileX*tileSizeX
endX = startX + tileSizeX
startY = nTileY*tileSizeY
endY = startY + tileSizeY;
if(endY > image.shape[0]):
endY = image.shape[0]
if(endX > image.shape[1]):
endX = image.shape[1]
if( makeLastPartFull == True and (nTileX == numTilesX-1 or nTileY == numTilesY-1) ):
startX = endX - tileSizeX
startY = endY - tileSizeY
currentTile = image[startY:endY, startX:endX]
cv2.imwrite(filename + '_%d_%d' % (nTileY, nTileX) + file_extension, currentTile)

This is for massive image reconstruction using part of flowfree his code. By using a folder of sliced images in the same area the script is, you can rebuild the image. I hope this helps.
import cv2
import glob
import os
dir = "."
pathname = os.path.join(dir, "*" + ".png")
images = [cv2.imread(img) for img in glob.glob(pathname)]
img = images[0]
numrows, numcols = 1,1
height = int(img.shape[0] / numrows)
width = int(img.shape[1] / numcols)
for row in range(numrows):
for col in range(numcols):
y0 = row * height
y1 = y0 + height
x0 = col * width
x1 = x0 + width
cv2.imwrite('merged_img_%d%d.jpg' % (row, col), img[y0:y1, x0:x1])

Related

Rounding errors using PIL.Image.Transform

I have high resolution scans of old navigational maps, that I have turned into map tiles with gdal2tiles. Now I want to write code for a live video stream that records a panning movement between two random points on the maps.
Initially, I had the code working by generating an image for each videoframe, that was assembled from a grid of map tiles. These are small jpg files of 256px x 256px. The next videoframe would then show the same map, but translated a certain amount in the x and y direction.
But opening each jpg with Image.open proved to be a bottleneck. So I tried to make the process faster by reusing the opened tiles until they disappeared off the frame, and only opening fresh tiles as needed. I basically first translate the whole image like so: newframe = oldframe.transform(oldframe.size, Image.AFFINE, data). Then I add a row of fresh tiles on top (or bottom, depending on direction of the panning motion), and on the side.
The problem
I seem to run into some rounding errors (I think), because the opened tiles do not always line up well with the existing, translated content. I have thin lines appearing. But I can't figure out where the rounding errors may come from. Nor how to avoid them.
Here is some code which shows the lines appearing, using colored empty images rather than the map tiles:
import time
import math
from random import randint, uniform
from PIL import Image
import numpy as np
import cv2
FRAMESIZE = (1920, 1080)
TILESIZE = 256
class MyMap:
'''A map class, to hold all its properties'''
def __init__(self) -> None:
self.maxzoom = 8
self.dimensions = (40000, 30000)
self.route, self.zoom = self.trajectory()
pass
def trajectory(self):
'''Calculate random trajectories from map dimensions'''
factor = uniform(1, 8)
extentwidth = FRAMESIZE[0] * factor
extentheight = FRAMESIZE[1] * factor
mapwidth, mapheight = (self.dimensions)
zdiff = math.log2(factor)
newzoom = math.ceil(self.maxzoom - zdiff)
a = (randint(0 + math.ceil(extentwidth / 2), mapwidth - math.floor(extentwidth / 2)),
randint(0 + math.ceil(extentheight / 2), mapheight - math.floor(extentheight / 2))) # Starting point
b = (randint(0 + math.ceil(extentwidth / 2), mapwidth - math.floor(extentwidth / 2)),
randint(0 + math.ceil(extentheight / 2), mapheight - math.floor(extentheight / 2))) # Ending point
x_distance = b[0] - a[0]
y_distance = b[1] - a[1]
distance = math.sqrt((x_distance**2)+(y_distance**2)) # Pythagoras
speed = 3 * factor # pixels per 25th of a second (video framerate)
steps = math.floor(distance / speed)
trajectory = []
for step in range(steps):
x = a[0] + step * x_distance / steps
y = a[1] + step * y_distance / steps
trajectory.append((x, y))
return trajectory, newzoom
def CreateFrame(self, point, oldframe = None):
x = round(self.route[point][0] / (2 ** (self.maxzoom - self.zoom)))
y = round(self.route[point][1] / (2 ** (self.maxzoom - self.zoom)))
if oldframe:
xtrns = x - round(self.route[point - 1][0] / (2 ** (self.maxzoom - self.zoom)))
ytrns = y - round(self.route[point - 1][1] / (2 ** (self.maxzoom - self.zoom)))
# print(x, self.route[point - 1][0] / (2 ** (self.maxzoom - self.zoom)))
west = int(x - FRAMESIZE[0] / 2)
east = int(x + FRAMESIZE[0] / 2)
north = int(y - FRAMESIZE[1] / 2)
south = int(y + FRAMESIZE[1] / 2)
xoffset = west % TILESIZE
yoffset = north % TILESIZE
xrange = range(math.floor(west / TILESIZE), math.ceil(east / TILESIZE))
yrange = range(math.floor(north / TILESIZE), math.ceil(south / TILESIZE))
if oldframe:
data = (
1, #a
0, #b
xtrns, #c +left/-right
0, #d
1, #e
ytrns #f +up/-down
)
newframe = oldframe.transform(oldframe.size, Image.AFFINE, data)
if ytrns < 0:
singlerow_ytile = yrange.start
elif ytrns > 0:
singlerow_ytile = yrange.stop - 1
if ytrns != 0:
for xtile in xrange:
try:
tile = Image.new('RGB', (TILESIZE, TILESIZE), (130,100,10))
newframe.paste(
tile,
(
(xtile - west // TILESIZE) * TILESIZE - xoffset,
(singlerow_ytile - north // TILESIZE) * TILESIZE - yoffset
)
)
except:
tile = None
if xtrns < 0:
singlerow_xtile = xrange.start
elif xtrns > 0:
singlerow_xtile = xrange.stop - 1
if xtrns != 0:
for ytile in yrange:
try:
tile = Image.new('RGB', (TILESIZE, TILESIZE), (200, 200, 200))
newframe.paste(
tile,
(
(singlerow_xtile - west // TILESIZE) * TILESIZE - xoffset,
(ytile - north // TILESIZE) * TILESIZE - yoffset
)
)
except:
tile = None
else:
newframe = Image.new('RGB',FRAMESIZE)
for xtile in xrange:
for ytile in yrange:
try:
tile = Image.new('RGB', (TILESIZE, TILESIZE), (100, 200, 20))
newframe.paste(
tile,
(
(xtile - west // TILESIZE) * TILESIZE - xoffset,
(ytile - north // TILESIZE) * TILESIZE - yoffset
)
)
except:
tile = None
return newframe
def preparedisplay(img, ago):
quit = False
open_cv_image = np.array(img)
# Convert RGB to BGR
open_cv_image = open_cv_image[:, :, ::-1].copy()
# Write some Text
font = cv2.FONT_HERSHEY_TRIPLEX
bottomLeftCornerOfText = (10,1050)
fontScale = 1
fontColor = (2,2,2)
thickness = 2
lineType = 2
cv2.putText(open_cv_image,f"Press q to exit. Frame took {(time.process_time() - ago) * 100}",
bottomLeftCornerOfText,
font,
fontScale,
fontColor,
thickness,
lineType)
return open_cv_image
#===============================================================================
quit = False
while not quit:
currentmap = MyMap()
previousframe = None
step = 0
while step < len(currentmap.route):
start = time.process_time()
currentframe = currentmap.CreateFrame(step, previousframe)
previousframe = currentframe
cv2.imshow('maps', preparedisplay(currentframe, start))
timeleft = (time.process_time() - start)
# print(timeleft)
# if cv2.waitKey(40 - int(timeleft * 100)) == ord('q'):
if cv2.waitKey(1) == ord('q'):
# press q to terminate the loop
cv2.destroyAllWindows()
quit = True
break
step += 1

Python-3, my program doesn't show a negative image

So I need to follow the function in my textbook, to make an image negative and show the negative image. I've tried changing a few things in to replicate the previous function see to if that would change anything like typing in what image I want to have a negative of. It compiles and runs fine showing no errors it just doesn't show me a negative of my image, so I don't know whats the issue.
from cImage import *
def negativePixel(oldPixel):
newRed = 255 - oldPixel.getRed()
newGreen = 255 - oldPixel.getGreen()
newBlue = 255 - oldPixel.getBlue()
newPixel = Pixel(newRed, newGreen, newBlue)
return newPixel`
def MakeNegative(imageFile):
oldImage = FileImage(imageFile)
width = oldImage.getWidth()
height = oldImage.getHeight()
myImageWindow = ImageWin("Negative Image", width * 2, height)
oldImage.draw(myImageWindow)
newIn = EmptyImage(width, height)
for row in range(height):
for col in range(width):
oldPixel = oldImage.getPixel(col, row)
newPixel = negativePixel(oldPixel)
newIn.setPixel(col, row, newPixel)
newIn.setPosition(width + 1, 0)
newIn.draw(myImageWindow)
myImageWindow.exitOnClick()
Your code wasn't compiling or running for me; I fixed a few things - indentation, import image (not cImage), not invoking MakeNegative(), parameters out of order, etc. This works for me. I'm on Ubuntu 18.04, Python 3.6.9, cImage-2.0.2, Pillow-7.2.0.
from image import *
def negativePixel(oldPixel):
newRed = 255 - oldPixel.getRed()
newGreen = 255 - oldPixel.getGreen()
newBlue = 255 - oldPixel.getBlue()
newPixel = Pixel(newRed, newGreen, newBlue)
return newPixel
def MakeNegative(imageFile):
oldImage = FileImage(imageFile)
width = oldImage.getWidth()
height = oldImage.getHeight()
myImageWindow = ImageWin(width * 2, height, "Negative Image")
oldImage.draw(myImageWindow)
newIn = EmptyImage(width, height)
for row in range(height):
for col in range(width):
oldPixel = oldImage.getPixel(col, row)
newPixel = negativePixel(oldPixel)
newIn.setPixel(col, row, newPixel)
newIn.setPosition(width + 1, 0)
newIn.draw(myImageWindow)
myImageWindow.exitOnClick()
MakeNegative('Lenna_test_image.png')

Find new (X,Y) after resizing and cropping image

I have a image that has to be cropped around a bounding box and resized to 256x256. In my original image I have an number of Points (x,y) that are in the bounding box.
This is my original image with my original coordinates marked:
Heres the cropped result, where the red points are the right x,y and the blue ones are my current result:
Heres how I'm doing it:
import numpy as np
import cv2
def scaleBB(bb, scale):
centerX = (bb[0][0] + bb[1][0]) / 2
centerY = (bb[0][1] + bb[2][1]) / 2
center = (centerX, centerY)
scl_center = (centerX * scale[0], centerY * scale[1])
p1 = scale * (bb[0] - center) + scl_center
p2 = scale * (bb[1] - center) + scl_center
p3 = scale * (bb[2] - center) + scl_center
p4 = scale * (bb[3] - center) + scl_center
return np.array([p1, p2, p3, p4])
def expandBB(scaledBB, size):
bbw = np.abs(scaledBB[0][0] - scaledBB[1][0])
bbh = np.abs(scaledBB[0][1] - scaledBB[2][1])
expandX = (size[0] - bbw) / 2
expandY = (size[1] - bbh) / 2
p1 = scaledBB[0] + (-expandX, -expandY)
p2 = scaledBB[1] + (+expandX, -expandY)
p3 = scaledBB[2] + (+expandX, +expandY)
p4 = scaledBB[3] + (+expandX, +expandY)
return np.array([p1, p2, p3, p4])
def recalculate_joints_points(oldX, oldY, newX, newY, joints):
R_x = newX / oldX
R_y = newY / oldY
new_joints = []
for index, joint in enumerate(joints):
x = joint[0]
y = joint[1]
n_x = round(R_x * x)
n_y = round(R_y * y)
print(R_x, R_y, x, y, n_x, n_y)
new_joints.append([n_x, n_y])
return np.array(new_joints)
def cropAndResizeImage(label, bb):
img_path = "original.jpg"
# downscale
image = cv2.imread(img_path)
# orgSize = image.shape[:2]
label = label
bb = bb
print(bb)
dim = int(256 / 2)
# define the target height of the bounding box
targetHeight = 200.0
w = np.abs(bb[0][0] - bb[1][0])
h = np.abs(bb[0][1] - bb[2][1])
targetScale = targetHeight / h
print(targetScale)
scaledImage = cv2.resize(image, (0, 0), fx=targetScale, fy=targetScale)
scaledBB = scaleBB(bb, (targetScale, targetScale))
cropRegion = expandBB(scaledBB, (256, 256))
print(scaledBB)
print(cropRegion)
startX = int(cropRegion[0][0] + dim)
startY = int(cropRegion[0][1] + dim)
endX = startX + 256 # cropRegion[2][0] + dim
endY = startY + 256 #cropRegion[2][1] + dim
print(startX, startY, endX, endY)
padded_image = np.pad(scaledImage, ((dim, dim), (dim, dim), (0, 0)), mode='constant')
croppedImage = padded_image[startY:endY, startX:endX]
# new label
print(image.shape, croppedImage.shape)
oldWidth = image.shape[1]
oldHeight = image.shape[0]
newWidth = 256 + dim
newHeight = 256 + dim
out_label = recalculate_joints_points(oldWidth, oldHeight, newWidth, newHeight, label)
return [croppedImage, out_label]
def main():
labels = np.array([[1214, 598],
[1169, 424],
[1238, 273],
[1267, 285],
[1212, 453],
[1229, 622],
[1253, 279],
[1173, 114],
[1171, 113],
[1050, 60],
[1106, 143],
[1140, 100],
[1169, 80],
[1176, 148],
[1152, 280],
[1087, 391]])
bb = np.array([[1050, 60],
[1267, 60],
[1267, 622],
[1050, 622]])
img, label = cropAndResizeImage(labels, bb)
for point in label:
print(point)
x,y = point
cv2.circle(img,(int(x),int(y)),5,(255,0,0),-11)
cv2.imshow("cropped", img)
cv2.waitKey()
if __name__ == '__main__':
main()
As far as I understood is to get the new (x,y) you have to calculate the ratio (difference of size in a scale factor) but it still seems off. Any help is appreciated.
EDIT 1:
Using as newHeight/Width just 256 produces this image:
*EDIT 2:
Using solution of #ChrisH its quite perfect but still a little bit off:
Here is a function that will translate directly from the original coordinates into the cropped and scaled coordinates. You can skip all the other functions and transform points directly with this
def getNewCoords(x,y):
bbUpperLeftX = bb[0][0]
bbUpperLeftY = bb[0][1]
bbLowerRightX = bb[2][0]
bbLowerRightY = bb[2][1]
sizeX = bbLowerRightX - bbUpperLeftX
sizeY = bbLowerRightY - bbUpperLeftY
sizeMax = max(sizeX, sizeY)
centerX = (bbLowerRightX + bbUpperLeftX)/2
centerY = (bbLowerRightY + bbUpperLeftY)/2
offsetX = (centerX-sizeMax/2)*256/sizeMax
offsetY = (centerY-sizeMax/2)*256/sizeMax
x = x * 256/sizeMax - offsetX
y = y * 256/sizeMax - offsetY
return (x,y)
Since you define
endX = startX + 256
endY = startY + 256
And make the output image as
croppedImage = padded_image[startY:endY, startX:endX]
Shouldn’t the new width and height be 256? instead you define them as
newWidth = 256 + dim
newHeight = 256 + dim
I think dim is unnecessary here
You can use augmentit to do the task.
pip install augmentit
Documentation
link : https://github.com/sandesha-hegde/augmentit

Remove all empty space from image

I need to remove all white-spaces from image but I don't know how to do it..
I am using trim functionality to trim white spaces from border but still white-spaces are present in middle of image I am attaching my original image from which I want to remove white-spaces
my code
from PIL import Image, ImageChops
import numpy
def trim(im):
bg = Image.new(im.mode, im.size, im.getpixel((0, 0)))
diff = ImageChops.difference(im, bg)
diff = ImageChops.add(diff, diff, 2.0, -100)
box = diff.getbbox()
if box:
im.crop(box).save("trim_pil.png")
im = Image.open("/home/einfochips/Documents/imagecomparsion/kroger_image_comparison/SnapshotImages/screenshot_Hide.png")
im = trim(im)
but this code only remove space from borders, I need to remove spaces from middle also. Please help if possible, it would be very good if I got all five images in different PNG file.
You could go the long way with a for loop
from PIL import Image, ImageChops
def getbox(im, color):
bg = Image.new(im.mode, im.size, color)
diff = ImageChops.difference(im, bg)
diff = ImageChops.add(diff, diff, 2.0, -100)
return diff.getbbox()
def split(im):
retur = []
emptyColor = im.getpixel((0, 0))
box = getbox(im, emptyColor)
width, height = im.size
pixels = im.getdata()
sub_start = 0
sub_width = 0
offset = box[1] * width
for x in range(width):
if pixels[x + offset] == emptyColor:
if sub_width > 0:
retur.append((sub_start, box[1], sub_width, box[3]))
sub_width = 0
sub_start = x + 1
else:
sub_width = x + 1
if sub_width > 0:
retur.append((sub_start, box[1], sub_width, box[3]))
return retur
This makes it easy to retrieve the crop boxes in the image like this:
im = Image.open("/home/einfochips/Documents/imagecomparsion/kroger_image_comparison/SnapshotImages/screenshot_Hide.png")
for idx, box in enumerate(split(im)):
im.crop(box).save("trim_{0}.png".format(idx))
If you already know the size of the images toy want to extract you could go with
def split(im, box):
retur = []
pixels = im.getdata()
emptyColor = pixels[0]
width, height = im.size;
y = 0;
while y < height - box[3]:
x = 0
y_step = 1
while x < width - box[2]:
x_step = 1
if pixels[y*width + x] != emptyColor:
retur.append((x, y, box[2] + x, box[3] + y))
y_step = box[3] + 1
x_step = box[2] + 1
x += x_step
y += y_step
return retur
Adding another parameter to the call
for idx, box in enumerate(split(im, (0, 0, 365, 150))):
im.crop(box).save("trim_{0}.png".format(idx))

Creating a montage of pictures in python

I have no experience with python, but the owner of this script is not responding.
When I drag my photos over this script, to create a montage, it ends up cutting off half of the last photo on the right side edge.
Being 4 pictures wide,
1 2 3 4
5 6 7 8
Pictures 4 and 8 usually get halved. The space is there for the pictures (its blank though)
I was wondering what would be causing this.
I have thought it is possible that it was cropping, but its almost like the half of the picture isn't imported or detected.
Well, you drag selected photos over the script , it outputs something like this
So you can take a bunch of photos or screenshots, and combine them into one single file, easily instead of adding each photo individually.
Size of each photo is roughly 500x250 at max.
EDIT:
Here is the upload of the preview, as you can see the images have the slots, but they are "disappearing" if that makes sense.
EDIT2:
This script has worked at one time, I haven't edited it or anything. It had worked on a ~70 screenshot montage. No errors or anything. Is there something that my computer could be doing to disrupt the importing of the images?
#!/usr/bin/env python
import os
import sys
from time import strftime
import Image
import ImageDraw
import ImageFont
# parameters
row_size = 4
margin = 3
def generate_montage(filenames):
images = [Image.open(filename) for filename in filenames]
width = 0
height = 0
i = 0
sum_x = max_y = 0
width = max(image.size[1]+margin for image in images)*row_size
height = sum(image.size[0]+margin for image in images)
montage = Image.new(mode='RGBA', size=(width, height), color=(0,0,0,0))
try:
image_font = ImageFont.truetype('font/Helvetica.ttf', 18)
except:
try:
image_font = ImageFont.load('font/Helvetica-18.pil')
except:
image_font = ImageFont.load_default()
draw = ImageDraw.Draw(montage)
offset_x = offset_y = 0
i = 0
max_y = 0
max_x = 0
offset_x = 0
for image in images:
montage.paste(image, (offset_x, offset_y))
text_coords = offset_x + image.size[0] - 45, offset_y + 120
draw.text(text_coords, '#{0}'.format(i+1), font=image_font)
max_x = max(max_x, offset_x+image.size[0])
if i % row_size == row_size-1:
offset_y += max_y+margin
max_y = 0
offset_x = 0
else:
offset_x += image.size[0]+margin
max_y = max(max_y, image.size[1])
i += 1
if i % row_size:
offset_y += max_y
filename = strftime("Montage %Y-%m-%d at %H.%M.%S.png")
montage = montage.crop((0, 0, max_x, offset_y))
montage.save(filename)
if __name__ == '__main__':
old_cwd = os.getcwd()
os.chdir(os.path.dirname(sys.argv[0]))
try:
if len(sys.argv) > 1:
generate_montage(sys.argv[1:])
finally:
os.chdir(old_cwd)
In the size calculation, you use image.size[1] for the width, but that's the height! Use image.size[0] for the width and image.size[1] for the height instead.
Also, a couple of minor stylistic notes:
Do you really need the script to always run from the program's directory? In any case, os.chdir(os.path.dirname(sys.argv[0])) prevents the program from being executed as ./montage.py, so you may want to use a abspath to allow the invocation from the current directory.
Instead of having to update the counter i, you can change the for loop to
for i,image in enumerate(images):
The following lines have no effect, since the variables are overwritten / never used:
width = 0
height = 0
i = 0
sum_x = max_y = 0
All in all, the code could look like this:
#!/usr/bin/env python
import os.path
import sys
from time import strftime
import Image
row_size = 4
margin = 3
def generate_montage(filenames, output_fn):
images = [Image.open(filename) for filename in filenames]
width = max(image.size[0] + margin for image in images)*row_size
height = sum(image.size[1] + margin for image in images)
montage = Image.new(mode='RGBA', size=(width, height), color=(0,0,0,0))
max_x = 0
max_y = 0
offset_x = 0
offset_y = 0
for i,image in enumerate(images):
montage.paste(image, (offset_x, offset_y))
max_x = max(max_x, offset_x + image.size[0])
max_y = max(max_y, offset_y + image.size[1])
if i % row_size == row_size-1:
offset_y = max_y + margin
offset_x = 0
else:
offset_x += margin + image.size[0]
montage = montage.crop((0, 0, max_x, max_y))
montage.save(output_fn)
if __name__ == '__main__':
basename = strftime("Montage %Y-%m-%d at %H.%M.%S.png")
exedir = os.path.dirname(os.path.abspath(sys.argv[0]))
filename = os.path.join(exedir, basename)
generate_montage(sys.argv[1:], filename)

Categories