Mercurial > hg > digilib-old
comparison servlet/src/digilib/image/ImageLoaderDocuImage.java @ 290:5d0c0da080ec gen2 scaleable_1
digilib servlet version 2 ("scaleable digilib")
- first stab at using thread pools to limit resource use
- using Dug Leas util.concurrent
- doesn't mix with tomcat :-(
author | robcast |
---|---|
date | Thu, 21 Oct 2004 20:53:37 +0200 |
parents | 0ff3ede32060 |
children |
comparison
equal
deleted
inserted
replaced
289:9f7b864f955f | 290:5d0c0da080ec |
---|---|
1 /* ImageLoaderDocuImage -- Image class implementation using JDK 1.4 ImageLoader | 1 /* ImageLoaderDocuImage -- Image class implementation using JDK 1.4 ImageLoader |
2 | 2 |
3 Digital Image Library servlet components | 3 Digital Image Library servlet components |
4 | 4 |
5 Copyright (C) 2001, 2002 Robert Casties (robcast@mail.berlios.de) | 5 Copyright (C) 2002, 2003 Robert Casties (robcast@mail.berlios.de) |
6 | 6 |
7 This program is free software; you can redistribute it and/or modify it | 7 This program is free software; you can redistribute it and/or modify it |
8 under the terms of the GNU General Public License as published by the | 8 under the terms of the GNU General Public License as published by the |
9 Free Software Foundation; either version 2 of the License, or (at your | 9 Free Software Foundation; either version 2 of the License, or (at your |
10 option) any later version. | 10 option) any later version. |
11 | 11 |
12 Please read license.txt for the full details. A copy of the GPL | 12 Please read license.txt for the full details. A copy of the GPL |
13 may be found at http://www.gnu.org/copyleft/lgpl.html | 13 may be found at http://www.gnu.org/copyleft/lgpl.html |
14 | 14 |
15 You should have received a copy of the GNU General Public License | 15 You should have received a copy of the GNU General Public License |
16 along with this program; if not, write to the Free Software | 16 along with this program; if not, write to the Free Software |
17 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA | 17 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA |
18 | 18 */ |
19 */ | |
20 | 19 |
21 package digilib.image; | 20 package digilib.image; |
22 | 21 |
23 import javax.servlet.*; | 22 import java.awt.Rectangle; |
24 import javax.servlet.http.*; | 23 import java.awt.RenderingHints; |
25 import java.io.*; | 24 import java.awt.geom.AffineTransform; |
26 import java.util.*; | 25 import java.awt.geom.Rectangle2D; |
27 | 26 import java.awt.image.AffineTransformOp; |
28 import java.awt.*; | 27 import java.awt.image.BufferedImage; |
29 import java.awt.image.*; | 28 import java.awt.image.ConvolveOp; |
30 import java.awt.geom.*; | 29 import java.awt.image.Kernel; |
31 import java.awt.image.renderable.*; | 30 import java.awt.image.RescaleOp; |
32 | 31 import java.io.File; |
33 import javax.imageio.*; | 32 import java.io.IOException; |
34 | 33 import java.io.OutputStream; |
35 import digilib.*; | 34 import java.io.RandomAccessFile; |
36 import digilib.io.*; | 35 import java.util.Iterator; |
37 | 36 |
37 import javax.imageio.ImageIO; | |
38 import javax.imageio.ImageReadParam; | |
39 import javax.imageio.ImageReader; | |
40 import javax.imageio.stream.FileImageInputStream; | |
41 import javax.imageio.stream.ImageInputStream; | |
42 | |
43 import digilib.io.FileOpException; | |
44 import digilib.io.ImageFile; | |
45 | |
46 /** Implementation of DocuImage using the ImageLoader API of Java 1.4 and Java2D. */ | |
38 public class ImageLoaderDocuImage extends DocuImageImpl { | 47 public class ImageLoaderDocuImage extends DocuImageImpl { |
39 | 48 |
40 private BufferedImage img; | 49 /** image object */ |
41 | 50 protected BufferedImage img; |
42 public ImageLoaderDocuImage() { | 51 |
43 } | 52 /** interpolation type */ |
44 | 53 protected RenderingHints renderHint; |
45 public ImageLoaderDocuImage(Utils u) { | 54 |
46 util = u; | 55 /** ImageIO image reader */ |
47 } | 56 protected ImageReader reader; |
48 | 57 |
49 /** | 58 /** File that was read */ |
50 * load image file | 59 protected File imgFile; |
51 */ | 60 |
52 public void loadImage(File f) throws FileOpException { | 61 /* loadSubimage is supported. */ |
53 util.dprintln(10, "loadImage!"); | 62 public boolean isSubimageSupported() { |
54 System.gc(); | 63 return true; |
55 try { | 64 } |
56 for (int i = 0; i < ImageIO.getReaderFormatNames().length; i++) { | 65 |
57 System.out.println("ImageLoader reader:"+ImageIO.getReaderFormatNames()[i]); | 66 public void setQuality(int qual) { |
58 } | 67 quality = qual; |
59 for (int i = 0; i < ImageIO.getWriterFormatNames().length; i++) { | 68 renderHint = new RenderingHints(null); |
60 System.out.println("ImageLoader writer:"+ImageIO.getWriterFormatNames()[i]); | 69 //hint.put(RenderingHints.KEY_ANTIALIASING, |
61 } | 70 // RenderingHints.VALUE_ANTIALIAS_OFF); |
62 img = ImageIO.read(f); | 71 // setup interpolation quality |
63 if (img == null) { | 72 if (qual > 0) { |
64 util.dprintln(3, "ERROR(loadImage): unable to load file"); | 73 logger.debug("quality q1"); |
65 throw new FileOpException("Unable to load File!"); | 74 renderHint.put(RenderingHints.KEY_INTERPOLATION, |
66 } | 75 RenderingHints.VALUE_INTERPOLATION_BICUBIC); |
67 } | 76 } else { |
68 catch (IOException e) { | 77 logger.debug("quality q0"); |
69 throw new FileOpException("Error reading image."); | 78 renderHint.put(RenderingHints.KEY_INTERPOLATION, |
70 } | 79 RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); |
71 } | 80 } |
72 | 81 } |
73 /** | 82 |
74 * write image of type mt to Stream | 83 public int getHeight() { |
75 */ | 84 int h = 0; |
76 public void writeImage(String mt, ServletResponse res) | 85 try { |
77 throws FileOpException { | 86 if (img == null) { |
78 util.dprintln(10, "writeImage!"); | 87 h = reader.getHeight(0); |
79 try { | 88 } else { |
80 // setup output | 89 h = img.getHeight(); |
81 String type = "png"; | 90 } |
82 if (mt == "image/jpeg") { | 91 } catch (IOException e) { |
83 type = "jpeg"; | 92 logger.debug("error in getHeight", e); |
84 } else if (mt == "image/png") { | 93 } |
85 type = "png"; | 94 return h; |
86 } else { | 95 } |
87 // unknown mime type | 96 |
88 util.dprintln(2, "ERROR(writeImage): Unknown mime type "+mt); | 97 public int getWidth() { |
89 throw new FileOpException("Unknown mime type: "+mt); | 98 int w = 0; |
90 } | 99 try { |
91 res.setContentType(mt); | 100 if (img == null) { |
92 // render output | 101 w = reader.getWidth(0); |
93 if (ImageIO.write(img, type, res.getOutputStream())) { | 102 } else { |
94 // writing was OK | 103 w = img.getWidth(); |
95 return; | 104 } |
96 } else { | 105 } catch (IOException e) { |
97 throw new FileOpException("Error writing image: Unknown image format!"); | 106 logger.debug("error in getHeight", e); |
98 } | 107 } |
99 } catch (IOException e) { | 108 return w; |
100 // e.printStackTrace(); | 109 } |
101 throw new FileOpException("Error writing image."); | 110 |
102 } | 111 /* load image file */ |
103 } | 112 public void loadImage(ImageFile f) throws FileOpException { |
104 | 113 logger.debug("loadImage " + f.getFile()); |
105 public int getWidth() { | 114 //System.gc(); |
106 if (img != null) { | 115 try { |
107 return img.getWidth(); | 116 img = ImageIO.read(f.getFile()); |
108 } | 117 if (img == null) { |
109 return 0; | 118 throw new FileOpException("Unable to load File!"); |
110 } | 119 } |
111 | 120 } catch (IOException e) { |
112 public int getHeight() { | 121 throw new FileOpException("Error reading image."); |
113 if (img != null) { | 122 } |
114 return img.getHeight(); | 123 } |
115 } | 124 |
116 return 0; | 125 /** |
117 } | 126 * Get an ImageReader for the image file. |
118 | 127 * |
119 /** | 128 * @return |
120 * crop and scale image | 129 */ |
121 * take rectangle width,height at position x_off,y_off | 130 public ImageReader getReader(ImageFile f) throws IOException { |
122 * and scale by scale | 131 logger.debug("preloadImage " + f.getFile()); |
123 */ | 132 if (reader != null) { |
124 public void cropAndScale(int x_off, int y_off, int width, int height, | 133 logger.debug("Reader was not null!"); |
125 float scale, int qual) throws ImageOpException { | 134 // clean up old reader |
126 util.dprintln(10, "cropAndScale!"); | 135 dispose(); |
127 | 136 } |
128 int scaleInt = 0; | 137 //System.gc(); |
129 // setup interpolation quality | 138 RandomAccessFile rf = new RandomAccessFile(f.getFile(), "r"); |
130 if (qual > 0) { | 139 ImageInputStream istream = new FileImageInputStream(rf); |
131 util.dprintln(4, "quality q1"); | 140 //Iterator readers = ImageIO.getImageReaders(istream); |
132 scaleInt = AffineTransformOp.TYPE_BILINEAR; | 141 String mt = f.getMimetype(); |
133 } else { | 142 logger.debug("File type:" + mt); |
134 util.dprintln(4, "quality q0"); | 143 Iterator readers = ImageIO.getImageReadersByMIMEType(mt); |
135 scaleInt = AffineTransformOp.TYPE_NEAREST_NEIGHBOR; | 144 if (!readers.hasNext()) { |
136 } | 145 throw new FileOpException("Unable to load File!"); |
137 | 146 } |
138 // setup Crop | 147 reader = (ImageReader) readers.next(); |
139 BufferedImage croppedImg = img.getSubimage(x_off, y_off, width, height); | 148 /* are there more readers? */ |
140 | 149 logger.debug("ImageIO: this reader: " + reader.getClass()); |
141 img = null; // free img | 150 while (readers.hasNext()) { |
142 util.dprintln(3, "CROP:"+croppedImg.getWidth()+"x"+croppedImg.getHeight()); //DEBUG | 151 logger.debug("ImageIO: next reader: " + readers.next().getClass()); |
143 // util.dprintln(2, " time "+(System.currentTimeMillis()-startTime)+"ms"); | 152 } |
144 | 153 //*/ |
145 if (croppedImg == null) { | 154 reader.setInput(istream); |
146 util.dprintln(2, "ERROR(cropAndScale): error in crop"); | 155 imgFile = f.getFile(); |
147 throw new ImageOpException("Unable to crop"); | 156 return reader; |
148 } | 157 } |
149 | 158 |
150 // setup scale | 159 /* Load an image file into the Object. */ |
151 AffineTransformOp scaleOp = new AffineTransformOp( | 160 public void loadSubimage(ImageFile f, Rectangle region, int prescale) |
152 AffineTransform.getScaleInstance(scale, scale), | 161 throws FileOpException { |
153 scaleInt); | 162 logger.debug("loadSubimage"); |
154 BufferedImage scaledImg = scaleOp.filter(croppedImg, null); | 163 //System.gc(); |
155 croppedImg = null; // free opCrop | 164 try { |
156 | 165 if ((reader == null) || (imgFile != f.getFile())) { |
157 if (scaledImg == null) { | 166 getReader(f); |
158 util.dprintln(2, "ERROR(cropAndScale): error in scale"); | 167 } |
159 throw new ImageOpException("Unable to scale"); | 168 // set up reader parameters |
160 } | 169 ImageReadParam readParam = reader.getDefaultReadParam(); |
161 img = scaledImg; | 170 readParam.setSourceRegion(region); |
162 } | 171 if (prescale > 1) { |
172 readParam.setSourceSubsampling(prescale, prescale, 0, 0); | |
173 } | |
174 // read image | |
175 logger.debug("loading.."); | |
176 img = reader.read(0, readParam); | |
177 logger.debug("loaded"); | |
178 } catch (IOException e) { | |
179 throw new FileOpException("Unable to load File!"); | |
180 } | |
181 if (img == null) { | |
182 throw new FileOpException("Unable to load File!"); | |
183 } | |
184 } | |
185 | |
186 /* write image of type mt to Stream */ | |
187 public void writeImage(String mt, OutputStream ostream) | |
188 throws FileOpException { | |
189 logger.debug("writeImage"); | |
190 try { | |
191 // setup output | |
192 String type = "png"; | |
193 if (mt == "image/jpeg") { | |
194 type = "jpeg"; | |
195 } else if (mt == "image/png") { | |
196 type = "png"; | |
197 } else { | |
198 // unknown mime type | |
199 throw new FileOpException("Unknown mime type: " + mt); | |
200 } | |
201 | |
202 /* | |
203 * JPEG doesn't do transparency so we have to convert any RGBA image | |
204 * to RGB :-( *Java2D BUG* | |
205 */ | |
206 if ((type == "jpeg") && (img.getColorModel().hasAlpha())) { | |
207 logger.debug("BARF: JPEG with transparency!!"); | |
208 int w = img.getWidth(); | |
209 int h = img.getHeight(); | |
210 // BufferedImage.TYPE_INT_RGB seems to be fastest (JDK1.4.1, | |
211 // OSX) | |
212 int destType = BufferedImage.TYPE_INT_RGB; | |
213 BufferedImage img2 = new BufferedImage(w, h, destType); | |
214 img2.createGraphics().drawImage(img, null, 0, 0); | |
215 img = img2; | |
216 } | |
217 | |
218 // render output | |
219 logger.debug("writing"); | |
220 if (ImageIO.write(img, type, ostream)) { | |
221 // writing was OK | |
222 return; | |
223 } else { | |
224 throw new FileOpException( | |
225 "Error writing image: Unknown image format!"); | |
226 } | |
227 } catch (IOException e) { | |
228 throw new FileOpException("Error writing image."); | |
229 } | |
230 } | |
231 | |
232 public void scale(double scale, double scaleY) throws ImageOpException { | |
233 logger.debug("scale"); | |
234 /* for downscaling in high quality the image is blurred first */ | |
235 if ((scale <= 0.5) && (quality > 1)) { | |
236 int bl = (int) Math.floor(1 / scale); | |
237 blur(bl); | |
238 } | |
239 /* then scaled */ | |
240 AffineTransformOp scaleOp = new AffineTransformOp(AffineTransform | |
241 .getScaleInstance(scale, scale), renderHint); | |
242 BufferedImage scaledImg = null; | |
243 // enforce destination image type (*Java2D BUG*) | |
244 int type = img.getType(); | |
245 // FIXME: which type would be best? | |
246 if ((quality > 0) && (type != 0)) { | |
247 logger.debug("creating destination image"); | |
248 Rectangle2D dstBounds = scaleOp.getBounds2D(img); | |
249 scaledImg = new BufferedImage((int) dstBounds.getWidth(), | |
250 (int) dstBounds.getHeight(), type); | |
251 } | |
252 logger.debug("scaling..."); | |
253 scaledImg = scaleOp.filter(img, scaledImg); | |
254 logger.debug("destination image type " + scaledImg.getType()); | |
255 if (scaledImg == null) { | |
256 throw new ImageOpException("Unable to scale"); | |
257 } | |
258 //DEBUG | |
259 logger.debug("SCALE: " + scale + " ->" + scaledImg.getWidth() + "x" | |
260 + scaledImg.getHeight()); | |
261 img = scaledImg; | |
262 scaledImg = null; | |
263 } | |
264 | |
265 public void blur(int radius) throws ImageOpException { | |
266 //DEBUG | |
267 logger.debug("blur: " + radius); | |
268 // minimum radius is 2 | |
269 int klen = Math.max(radius, 2); | |
270 // FIXME: use constant kernels for most common sizes | |
271 int ksize = klen * klen; | |
272 // kernel is constant 1/k | |
273 float f = 1f / ksize; | |
274 float[] kern = new float[ksize]; | |
275 for (int i = 0; i < ksize; i++) { | |
276 kern[i] = f; | |
277 } | |
278 Kernel blur = new Kernel(klen, klen, kern); | |
279 // blur with convolve operation | |
280 ConvolveOp blurOp = new ConvolveOp(blur, ConvolveOp.EDGE_NO_OP, | |
281 renderHint); | |
282 // blur needs explicit destination image type for color *Java2D BUG* | |
283 BufferedImage blurredImg = null; | |
284 if (img.getType() == BufferedImage.TYPE_3BYTE_BGR) { | |
285 blurredImg = new BufferedImage(img.getWidth(), img.getHeight(), img | |
286 .getType()); | |
287 } | |
288 blurredImg = blurOp.filter(img, blurredImg); | |
289 if (blurredImg == null) { | |
290 throw new ImageOpException("Unable to scale"); | |
291 } | |
292 img = blurredImg; | |
293 } | |
294 | |
295 public void crop(int x_off, int y_off, int width, int height) | |
296 throws ImageOpException { | |
297 // setup Crop | |
298 BufferedImage croppedImg = img.getSubimage(x_off, y_off, width, height); | |
299 logger.debug("CROP:" + croppedImg.getWidth() + "x" | |
300 + croppedImg.getHeight()); | |
301 //DEBUG | |
302 // util.dprintln(2, " time | |
303 // "+(System.currentTimeMillis()-startTime)+"ms"); | |
304 if (croppedImg == null) { | |
305 throw new ImageOpException("Unable to crop"); | |
306 } | |
307 img = croppedImg; | |
308 } | |
309 | |
310 public void enhance(float mult, float add) throws ImageOpException { | |
311 /* | |
312 * Only one constant should work regardless of the number of bands | |
313 * according to the JDK spec. Doesn't work on JDK 1.4 for OSX and Linux | |
314 * (at least). RescaleOp scaleOp = new RescaleOp( (float)mult, | |
315 * (float)add, null); scaleOp.filter(img, img); | |
316 */ | |
317 | |
318 /* The number of constants must match the number of bands in the image. */ | |
319 int ncol = img.getColorModel().getNumComponents(); | |
320 float[] dm = new float[ncol]; | |
321 float[] da = new float[ncol]; | |
322 for (int i = 0; i < ncol; i++) { | |
323 dm[i] = (float) mult; | |
324 da[i] = (float) add; | |
325 } | |
326 RescaleOp scaleOp = new RescaleOp(dm, da, null); | |
327 scaleOp.filter(img, img); | |
328 } | |
329 | |
330 public void enhanceRGB(float[] rgbm, float[] rgba) throws ImageOpException { | |
331 | |
332 /* | |
333 * The number of constants must match the number of bands in the image. | |
334 * We do only 3 (RGB) bands. | |
335 */ | |
336 | |
337 int ncol = img.getColorModel().getNumColorComponents(); | |
338 if ((ncol != 3) || (rgbm.length != 3) || (rgba.length != 3)) { | |
339 logger | |
340 .debug("ERROR(enhance): unknown number of color bands or coefficients (" | |
341 + ncol + ")"); | |
342 return; | |
343 } | |
344 RescaleOp scaleOp = new RescaleOp(rgbOrdered(rgbm), rgbOrdered(rgba), | |
345 null); | |
346 scaleOp.filter(img, img); | |
347 } | |
348 | |
349 /** | |
350 * Ensures that the array f is in the right order to map the images RGB | |
351 * components. (not shure what happens | |
352 */ | |
353 public float[] rgbOrdered(float[] fa) { | |
354 /* | |
355 * TODO: this is UGLY, UGLY!! | |
356 */ | |
357 float[] fb; | |
358 int t = img.getType(); | |
359 if (img.getColorModel().hasAlpha()) { | |
360 fb = new float[4]; | |
361 if ((t == BufferedImage.TYPE_INT_ARGB) | |
362 || (t == BufferedImage.TYPE_INT_ARGB_PRE)) { | |
363 // RGB Type | |
364 fb[0] = fa[0]; | |
365 fb[1] = fa[1]; | |
366 fb[2] = fa[2]; | |
367 fb[3] = 1f; | |
368 } else { | |
369 // this isn't tested :-( | |
370 fb[0] = 1f; | |
371 fb[1] = fa[0]; | |
372 fb[2] = fa[1]; | |
373 fb[3] = fa[2]; | |
374 } | |
375 } else { | |
376 fb = new float[3]; | |
377 if (t == BufferedImage.TYPE_3BYTE_BGR) { | |
378 // BGR Type (actually it looks like RBG...) | |
379 fb[0] = fa[0]; | |
380 fb[1] = fa[2]; | |
381 fb[2] = fa[1]; | |
382 } else { | |
383 fb[0] = fa[0]; | |
384 fb[1] = fa[1]; | |
385 fb[2] = fa[2]; | |
386 } | |
387 } | |
388 return fb; | |
389 } | |
390 | |
391 public void rotate(double angle) throws ImageOpException { | |
392 // setup rotation | |
393 double rangle = Math.toRadians(angle); | |
394 // create offset to make shure the rotated image has no negative | |
395 // coordinates | |
396 double w = img.getWidth(); | |
397 double h = img.getHeight(); | |
398 AffineTransform trafo = new AffineTransform(); | |
399 // center of rotation | |
400 double x = (w / 2); | |
401 double y = (h / 2); | |
402 trafo.rotate(rangle, x, y); | |
403 // try rotation to see how far we're out of bounds | |
404 AffineTransformOp rotOp = new AffineTransformOp(trafo, renderHint); | |
405 Rectangle2D rotbounds = rotOp.getBounds2D(img); | |
406 double xoff = rotbounds.getX(); | |
407 double yoff = rotbounds.getY(); | |
408 // move image back in line | |
409 trafo | |
410 .preConcatenate(AffineTransform.getTranslateInstance(-xoff, | |
411 -yoff)); | |
412 // transform image | |
413 rotOp = new AffineTransformOp(trafo, renderHint); | |
414 BufferedImage rotImg = rotOp.filter(img, null); | |
415 // calculate new bounding box | |
416 //Rectangle2D bounds = rotOp.getBounds2D(img); | |
417 if (rotImg == null) { | |
418 throw new ImageOpException("Unable to rotate"); | |
419 } | |
420 img = rotImg; | |
421 // crop new image (with self-made rounding) | |
422 /* | |
423 * img = rotImg.getSubimage( (int) (bounds.getX()+0.5), (int) | |
424 * (bounds.getY()+0.5), (int) (bounds.getWidth()+0.5), (int) | |
425 * (bounds.getHeight()+0.5)); | |
426 */ | |
427 } | |
428 | |
429 public void mirror(double angle) throws ImageOpException { | |
430 // setup mirror | |
431 double mx = 1; | |
432 double my = 1; | |
433 double tx = 0; | |
434 double ty = 0; | |
435 if (Math.abs(angle - 0) < epsilon) { // 0 degree | |
436 mx = -1; | |
437 tx = getWidth(); | |
438 } else if (Math.abs(angle - 90) < epsilon) { // 90 degree | |
439 my = -1; | |
440 ty = getHeight(); | |
441 } else if (Math.abs(angle - 180) < epsilon) { // 180 degree | |
442 mx = -1; | |
443 tx = getWidth(); | |
444 } else if (Math.abs(angle - 270) < epsilon) { // 270 degree | |
445 my = -1; | |
446 ty = getHeight(); | |
447 } else if (Math.abs(angle - 360) < epsilon) { // 360 degree | |
448 mx = -1; | |
449 tx = getWidth(); | |
450 } | |
451 AffineTransformOp mirOp = new AffineTransformOp(new AffineTransform(mx, | |
452 0, 0, my, tx, ty), renderHint); | |
453 BufferedImage mirImg = mirOp.filter(img, null); | |
454 if (mirImg == null) { | |
455 throw new ImageOpException("Unable to mirror"); | |
456 } | |
457 img = mirImg; | |
458 } | |
459 | |
460 /* | |
461 * (non-Javadoc) | |
462 * | |
463 * @see java.lang.Object#finalize() | |
464 */ | |
465 protected void finalize() throws Throwable { | |
466 dispose(); | |
467 super.finalize(); | |
468 } | |
469 | |
470 public void dispose() { | |
471 // we must dispose the ImageReader because it keeps the filehandle | |
472 // open! | |
473 if (reader != null) { | |
474 reader.dispose(); | |
475 reader = null; | |
476 } | |
477 img = null; | |
478 } | |
163 | 479 |
164 } | 480 } |