annotate data/model/EditableDenseThreeDimensionalModel.cpp @ 875:3e6ed8a8577b tonioni

Use a sparse time-value model only for outputs with fixed bin count of 1, not for those with unknown bin count. (Precursor to using more than one model for outputs with unknown bin count)
author Chris Cannam
date Tue, 28 Jan 2014 18:52:22 +0000
parents e802e550a1f2
children 48410857b03c
rev   line source
Chris@152 1 /* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */
Chris@152 2
Chris@152 3 /*
Chris@152 4 Sonic Visualiser
Chris@152 5 An audio file viewer and annotation editor.
Chris@152 6 Centre for Digital Music, Queen Mary, University of London.
Chris@202 7 This file copyright 2006 Chris Cannam and QMUL.
Chris@152 8
Chris@152 9 This program is free software; you can redistribute it and/or
Chris@152 10 modify it under the terms of the GNU General Public License as
Chris@152 11 published by the Free Software Foundation; either version 2 of the
Chris@152 12 License, or (at your option) any later version. See the file
Chris@152 13 COPYING included with this distribution for more information.
Chris@152 14 */
Chris@152 15
Chris@152 16 #include "EditableDenseThreeDimensionalModel.h"
Chris@152 17
Chris@478 18 #include "base/LogRange.h"
Chris@478 19
Chris@152 20 #include <QTextStream>
Chris@387 21 #include <QStringList>
Chris@536 22 #include <QReadLocker>
Chris@536 23 #include <QWriteLocker>
Chris@387 24
Chris@181 25 #include <iostream>
Chris@181 26
Chris@256 27 #include <cmath>
Chris@534 28 #include <cassert>
Chris@256 29
Chris@607 30 #include "system/System.h"
Chris@607 31
Chris@152 32 EditableDenseThreeDimensionalModel::EditableDenseThreeDimensionalModel(size_t sampleRate,
Chris@152 33 size_t resolution,
Chris@152 34 size_t yBinCount,
Chris@535 35 CompressionType compression,
Chris@152 36 bool notifyOnAdd) :
Chris@611 37 m_startFrame(0),
Chris@152 38 m_sampleRate(sampleRate),
Chris@152 39 m_resolution(resolution),
Chris@152 40 m_yBinCount(yBinCount),
Chris@535 41 m_compression(compression),
Chris@152 42 m_minimum(0.0),
Chris@152 43 m_maximum(0.0),
Chris@256 44 m_haveExtents(false),
Chris@152 45 m_notifyOnAdd(notifyOnAdd),
Chris@152 46 m_sinceLastNotifyMin(-1),
Chris@152 47 m_sinceLastNotifyMax(-1),
Chris@152 48 m_completion(100)
Chris@152 49 {
Chris@152 50 }
Chris@152 51
Chris@152 52 bool
Chris@152 53 EditableDenseThreeDimensionalModel::isOK() const
Chris@152 54 {
Chris@152 55 return true;
Chris@152 56 }
Chris@152 57
Chris@152 58 size_t
Chris@152 59 EditableDenseThreeDimensionalModel::getSampleRate() const
Chris@152 60 {
Chris@152 61 return m_sampleRate;
Chris@152 62 }
Chris@152 63
Chris@152 64 size_t
Chris@152 65 EditableDenseThreeDimensionalModel::getStartFrame() const
Chris@152 66 {
Chris@611 67 return m_startFrame;
Chris@611 68 }
Chris@611 69
Chris@611 70 void
Chris@611 71 EditableDenseThreeDimensionalModel::setStartFrame(size_t f)
Chris@611 72 {
Chris@611 73 m_startFrame = f;
Chris@152 74 }
Chris@152 75
Chris@152 76 size_t
Chris@152 77 EditableDenseThreeDimensionalModel::getEndFrame() const
Chris@152 78 {
Chris@152 79 return m_resolution * m_data.size() + (m_resolution - 1);
Chris@152 80 }
Chris@152 81
Chris@152 82 Model *
Chris@152 83 EditableDenseThreeDimensionalModel::clone() const
Chris@152 84 {
Chris@536 85 QReadLocker locker(&m_lock);
Chris@534 86
Chris@152 87 EditableDenseThreeDimensionalModel *model =
Chris@152 88 new EditableDenseThreeDimensionalModel
Chris@535 89 (m_sampleRate, m_resolution, m_yBinCount, m_compression);
Chris@152 90
Chris@152 91 model->m_minimum = m_minimum;
Chris@152 92 model->m_maximum = m_maximum;
Chris@256 93 model->m_haveExtents = m_haveExtents;
Chris@152 94
Chris@152 95 for (size_t i = 0; i < m_data.size(); ++i) {
Chris@533 96 model->setColumn(i, m_data.at(i));
Chris@152 97 }
Chris@152 98
Chris@152 99 return model;
Chris@152 100 }
Chris@152 101
Chris@152 102 size_t
Chris@152 103 EditableDenseThreeDimensionalModel::getResolution() const
Chris@152 104 {
Chris@152 105 return m_resolution;
Chris@152 106 }
Chris@152 107
Chris@152 108 void
Chris@152 109 EditableDenseThreeDimensionalModel::setResolution(size_t sz)
Chris@152 110 {
Chris@152 111 m_resolution = sz;
Chris@152 112 }
Chris@152 113
Chris@152 114 size_t
Chris@182 115 EditableDenseThreeDimensionalModel::getWidth() const
Chris@182 116 {
Chris@182 117 return m_data.size();
Chris@182 118 }
Chris@182 119
Chris@182 120 size_t
Chris@182 121 EditableDenseThreeDimensionalModel::getHeight() const
Chris@152 122 {
Chris@152 123 return m_yBinCount;
Chris@152 124 }
Chris@152 125
Chris@152 126 void
Chris@182 127 EditableDenseThreeDimensionalModel::setHeight(size_t sz)
Chris@152 128 {
Chris@152 129 m_yBinCount = sz;
Chris@152 130 }
Chris@152 131
Chris@152 132 float
Chris@152 133 EditableDenseThreeDimensionalModel::getMinimumLevel() const
Chris@152 134 {
Chris@152 135 return m_minimum;
Chris@152 136 }
Chris@152 137
Chris@152 138 void
Chris@152 139 EditableDenseThreeDimensionalModel::setMinimumLevel(float level)
Chris@152 140 {
Chris@152 141 m_minimum = level;
Chris@152 142 }
Chris@152 143
Chris@152 144 float
Chris@152 145 EditableDenseThreeDimensionalModel::getMaximumLevel() const
Chris@152 146 {
Chris@152 147 return m_maximum;
Chris@152 148 }
Chris@152 149
Chris@152 150 void
Chris@152 151 EditableDenseThreeDimensionalModel::setMaximumLevel(float level)
Chris@152 152 {
Chris@152 153 m_maximum = level;
Chris@152 154 }
Chris@152 155
Chris@533 156 EditableDenseThreeDimensionalModel::Column
Chris@533 157 EditableDenseThreeDimensionalModel::getColumn(size_t index) const
Chris@152 158 {
Chris@536 159 QReadLocker locker(&m_lock);
Chris@534 160 if (index >= m_data.size()) return Column();
Chris@534 161 return expandAndRetrieve(index);
Chris@152 162 }
Chris@152 163
Chris@152 164 float
Chris@182 165 EditableDenseThreeDimensionalModel::getValueAt(size_t index, size_t n) const
Chris@152 166 {
Chris@534 167 Column c = getColumn(index);
Chris@535 168 if (n < c.size()) return c.at(n);
Chris@534 169 return m_minimum;
Chris@534 170 }
Chris@152 171
Chris@535 172 //static int given = 0, stored = 0;
Chris@534 173
Chris@534 174 void
Chris@534 175 EditableDenseThreeDimensionalModel::truncateAndStore(size_t index,
Chris@534 176 const Column &values)
Chris@534 177 {
Chris@534 178 assert(index < m_data.size());
Chris@534 179
Chris@843 180 //cout << "truncateAndStore(" << index << ", " << values.size() << ")" << endl;
Chris@534 181
Chris@535 182 // The default case is to store the entire column at m_data[index]
Chris@535 183 // and place 0 at m_trunc[index] to indicate that it has not been
Chris@535 184 // truncated. We only do clever stuff if one of the clever-stuff
Chris@535 185 // tests works out.
Chris@535 186
Chris@534 187 m_trunc[index] = 0;
Chris@535 188 if (index == 0 ||
Chris@535 189 m_compression == NoCompression ||
Chris@535 190 values.size() != m_yBinCount) {
Chris@535 191 // given += values.size();
Chris@535 192 // stored += values.size();
Chris@534 193 m_data[index] = values;
Chris@534 194 return;
Chris@152 195 }
Chris@152 196
Chris@535 197 // Maximum distance between a column and the one we refer to as
Chris@535 198 // the source of its truncated values. Limited by having to fit
Chris@535 199 // in a signed char, but in any case small values are usually
Chris@535 200 // better
Chris@535 201 static int maxdist = 6;
Chris@534 202
Chris@535 203 bool known = false; // do we know whether to truncate at top or bottom?
Chris@535 204 bool top = false; // if we do know, will we truncate at top?
Chris@534 205
Chris@535 206 // If the previous column is not truncated, then it is the only
Chris@535 207 // candidate for comparison. If it is truncated, then the column
Chris@535 208 // that it refers to is the only candidate. Either way, we only
Chris@535 209 // have one possible column to compare against here, and we are
Chris@535 210 // being careful to ensure it is not a truncated one (to avoid
Chris@535 211 // doing more work recursively when uncompressing).
Chris@534 212 int tdist = 1;
Chris@534 213 int ptrunc = m_trunc[index-1];
Chris@534 214 if (ptrunc < 0) {
Chris@534 215 top = false;
Chris@534 216 known = true;
Chris@534 217 tdist = -ptrunc + 1;
Chris@534 218 } else if (ptrunc > 0) {
Chris@534 219 top = true;
Chris@534 220 known = true;
Chris@534 221 tdist = ptrunc + 1;
Chris@534 222 }
Chris@534 223
Chris@534 224 Column p = expandAndRetrieve(index - tdist);
Chris@534 225 int h = m_yBinCount;
Chris@534 226
Chris@534 227 if (p.size() == h && tdist <= maxdist) {
Chris@534 228
Chris@534 229 int bcount = 0, tcount = 0;
Chris@534 230 if (!known || !top) {
Chris@535 231 // count how many identical values there are at the bottom
Chris@534 232 for (int i = 0; i < h; ++i) {
Chris@534 233 if (values.at(i) == p.at(i)) ++bcount;
Chris@534 234 else break;
Chris@534 235 }
Chris@534 236 }
Chris@534 237 if (!known || top) {
Chris@535 238 // count how many identical values there are at the top
Chris@534 239 for (int i = h; i > 0; --i) {
Chris@534 240 if (values.at(i-1) == p.at(i-1)) ++tcount;
Chris@534 241 else break;
Chris@534 242 }
Chris@534 243 }
Chris@534 244 if (!known) top = (tcount > bcount);
Chris@534 245
Chris@535 246 int limit = h / 4; // don't bother unless we have at least this many
Chris@534 247 if ((top ? tcount : bcount) > limit) {
Chris@534 248
Chris@534 249 if (!top) {
Chris@535 250 // create a new column with h - bcount values from bcount up
Chris@534 251 Column tcol(h - bcount);
Chris@535 252 // given += values.size();
Chris@535 253 // stored += h - bcount;
Chris@534 254 for (int i = bcount; i < h; ++i) {
Chris@534 255 tcol[i - bcount] = values.at(i);
Chris@534 256 }
Chris@534 257 m_data[index] = tcol;
Chris@534 258 m_trunc[index] = -tdist;
Chris@534 259 return;
Chris@534 260 } else {
Chris@535 261 // create a new column with h - tcount values from 0 up
Chris@534 262 Column tcol(h - tcount);
Chris@535 263 // given += values.size();
Chris@535 264 // stored += h - tcount;
Chris@534 265 for (int i = 0; i < h - tcount; ++i) {
Chris@534 266 tcol[i] = values.at(i);
Chris@534 267 }
Chris@534 268 m_data[index] = tcol;
Chris@534 269 m_trunc[index] = tdist;
Chris@534 270 return;
Chris@534 271 }
Chris@534 272 }
Chris@534 273 }
Chris@534 274
Chris@535 275 // given += values.size();
Chris@535 276 // stored += values.size();
Chris@843 277 // cout << "given: " << given << ", stored: " << stored << " ("
Chris@843 278 // << ((float(stored) / float(given)) * 100.f) << "%)" << endl;
Chris@534 279
Chris@535 280 // default case if nothing wacky worked out
Chris@534 281 m_data[index] = values;
Chris@534 282 return;
Chris@534 283 }
Chris@534 284
Chris@534 285 EditableDenseThreeDimensionalModel::Column
Chris@534 286 EditableDenseThreeDimensionalModel::expandAndRetrieve(size_t index) const
Chris@534 287 {
Chris@535 288 // See comment above m_trunc declaration in header
Chris@535 289
Chris@534 290 assert(index < m_data.size());
Chris@534 291 Column c = m_data.at(index);
Chris@534 292 if (index == 0) {
Chris@534 293 return c;
Chris@534 294 }
Chris@534 295 int trunc = (int)m_trunc[index];
Chris@534 296 if (trunc == 0) {
Chris@534 297 return c;
Chris@534 298 }
Chris@534 299 bool top = true;
Chris@534 300 int tdist = trunc;
Chris@534 301 if (trunc < 0) { top = false; tdist = -trunc; }
Chris@534 302 Column p = expandAndRetrieve(index - tdist);
Chris@537 303 int psize = p.size(), csize = c.size();
Chris@537 304 if (psize != m_yBinCount) {
Chris@843 305 cerr << "WARNING: EditableDenseThreeDimensionalModel::expandAndRetrieve: Trying to expand from incorrectly sized column" << endl;
Chris@534 306 }
Chris@534 307 if (top) {
Chris@537 308 for (int i = csize; i < psize; ++i) {
Chris@534 309 c.push_back(p.at(i));
Chris@534 310 }
Chris@534 311 } else {
Chris@593 312 // push_front is very slow on QVector -- but not enough to
Chris@593 313 // make it desirable to choose a different container, since
Chris@593 314 // QVector has all the other advantages for us. easier to
Chris@593 315 // write the whole array out to a new vector
Chris@593 316 Column cc(psize);
Chris@593 317 for (int i = 0; i < psize - csize; ++i) {
Chris@593 318 cc[i] = p.at(i);
Chris@534 319 }
Chris@593 320 for (int i = 0; i < csize; ++i) {
Chris@593 321 cc[i + (psize - csize)] = c.at(i);
Chris@593 322 }
Chris@593 323 return cc;
Chris@534 324 }
Chris@534 325 return c;
Chris@152 326 }
Chris@152 327
Chris@152 328 void
Chris@182 329 EditableDenseThreeDimensionalModel::setColumn(size_t index,
Chris@182 330 const Column &values)
Chris@152 331 {
Chris@536 332 QWriteLocker locker(&m_lock);
Chris@152 333
Chris@182 334 while (index >= m_data.size()) {
Chris@182 335 m_data.push_back(Column());
Chris@534 336 m_trunc.push_back(0);
Chris@152 337 }
Chris@152 338
Chris@152 339 bool allChange = false;
Chris@152 340
Chris@534 341 // if (values.size() > m_yBinCount) m_yBinCount = values.size();
Chris@439 342
Chris@152 343 for (size_t i = 0; i < values.size(); ++i) {
Chris@256 344 float value = values[i];
Chris@606 345 if (ISNAN(value) || ISINF(value)) {
Chris@256 346 continue;
Chris@256 347 }
Chris@256 348 if (!m_haveExtents || value < m_minimum) {
Chris@256 349 m_minimum = value;
Chris@152 350 allChange = true;
Chris@152 351 }
Chris@256 352 if (!m_haveExtents || value > m_maximum) {
Chris@256 353 m_maximum = value;
Chris@152 354 allChange = true;
Chris@152 355 }
Chris@256 356 m_haveExtents = true;
Chris@152 357 }
Chris@152 358
Chris@534 359 truncateAndStore(index, values);
Chris@534 360
Chris@593 361 // assert(values == expandAndRetrieve(index));
Chris@152 362
Chris@182 363 long windowStart = index;
Chris@182 364 windowStart *= m_resolution;
Chris@182 365
Chris@152 366 if (m_notifyOnAdd) {
Chris@152 367 if (allChange) {
Chris@152 368 emit modelChanged();
Chris@152 369 } else {
Chris@152 370 emit modelChanged(windowStart, windowStart + m_resolution);
Chris@152 371 }
Chris@152 372 } else {
Chris@152 373 if (allChange) {
Chris@152 374 m_sinceLastNotifyMin = -1;
Chris@152 375 m_sinceLastNotifyMax = -1;
Chris@152 376 emit modelChanged();
Chris@152 377 } else {
Chris@152 378 if (m_sinceLastNotifyMin == -1 ||
Chris@152 379 windowStart < m_sinceLastNotifyMin) {
Chris@152 380 m_sinceLastNotifyMin = windowStart;
Chris@152 381 }
Chris@152 382 if (m_sinceLastNotifyMax == -1 ||
Chris@152 383 windowStart > m_sinceLastNotifyMax) {
Chris@152 384 m_sinceLastNotifyMax = windowStart;
Chris@152 385 }
Chris@152 386 }
Chris@152 387 }
Chris@152 388 }
Chris@152 389
Chris@152 390 QString
Chris@152 391 EditableDenseThreeDimensionalModel::getBinName(size_t n) const
Chris@152 392 {
Chris@152 393 if (m_binNames.size() > n) return m_binNames[n];
Chris@152 394 else return "";
Chris@152 395 }
Chris@152 396
Chris@152 397 void
Chris@152 398 EditableDenseThreeDimensionalModel::setBinName(size_t n, QString name)
Chris@152 399 {
Chris@152 400 while (m_binNames.size() <= n) m_binNames.push_back("");
Chris@152 401 m_binNames[n] = name;
Chris@152 402 emit modelChanged();
Chris@152 403 }
Chris@152 404
Chris@152 405 void
Chris@152 406 EditableDenseThreeDimensionalModel::setBinNames(std::vector<QString> names)
Chris@152 407 {
Chris@152 408 m_binNames = names;
Chris@152 409 emit modelChanged();
Chris@152 410 }
Chris@152 411
Chris@478 412 bool
Chris@478 413 EditableDenseThreeDimensionalModel::shouldUseLogValueScale() const
Chris@478 414 {
Chris@536 415 QReadLocker locker(&m_lock);
Chris@534 416
Chris@533 417 QVector<float> sample;
Chris@533 418 QVector<int> n;
Chris@478 419
Chris@478 420 for (int i = 0; i < 10; ++i) {
Chris@478 421 size_t index = i * 10;
Chris@478 422 if (index < m_data.size()) {
Chris@533 423 const Column &c = m_data.at(index);
Chris@478 424 while (c.size() > sample.size()) {
Chris@478 425 sample.push_back(0.f);
Chris@478 426 n.push_back(0);
Chris@478 427 }
Chris@478 428 for (int j = 0; j < c.size(); ++j) {
Chris@533 429 sample[j] += c.at(j);
Chris@478 430 ++n[j];
Chris@478 431 }
Chris@478 432 }
Chris@478 433 }
Chris@478 434
Chris@478 435 if (sample.empty()) return false;
Chris@478 436 for (int j = 0; j < sample.size(); ++j) {
Chris@478 437 if (n[j]) sample[j] /= n[j];
Chris@478 438 }
Chris@478 439
Chris@533 440 return LogRange::useLogScale(sample.toStdVector());
Chris@478 441 }
Chris@478 442
Chris@152 443 void
Chris@333 444 EditableDenseThreeDimensionalModel::setCompletion(int completion, bool update)
Chris@152 445 {
Chris@152 446 if (m_completion != completion) {
Chris@152 447 m_completion = completion;
Chris@152 448
Chris@152 449 if (completion == 100) {
Chris@152 450
Chris@152 451 m_notifyOnAdd = true; // henceforth
Chris@152 452 emit modelChanged();
Chris@152 453
Chris@152 454 } else if (!m_notifyOnAdd) {
Chris@152 455
Chris@333 456 if (update &&
Chris@333 457 m_sinceLastNotifyMin >= 0 &&
Chris@152 458 m_sinceLastNotifyMax >= 0) {
Chris@152 459 emit modelChanged(m_sinceLastNotifyMin,
Chris@152 460 m_sinceLastNotifyMax + m_resolution);
Chris@152 461 m_sinceLastNotifyMin = m_sinceLastNotifyMax = -1;
Chris@152 462 } else {
Chris@152 463 emit completionChanged();
Chris@152 464 }
Chris@152 465 } else {
Chris@152 466 emit completionChanged();
Chris@152 467 }
Chris@152 468 }
Chris@152 469 }
Chris@152 470
Chris@318 471 QString
Chris@318 472 EditableDenseThreeDimensionalModel::toDelimitedDataString(QString delimiter) const
Chris@318 473 {
Chris@536 474 QReadLocker locker(&m_lock);
Chris@318 475 QString s;
Chris@318 476 for (size_t i = 0; i < m_data.size(); ++i) {
Chris@318 477 QStringList list;
Chris@533 478 for (size_t j = 0; j < m_data.at(i).size(); ++j) {
Chris@533 479 list << QString("%1").arg(m_data.at(i).at(j));
Chris@318 480 }
Chris@318 481 s += list.join(delimiter) + "\n";
Chris@318 482 }
Chris@318 483 return s;
Chris@318 484 }
Chris@318 485
Chris@838 486 QString
Chris@838 487 EditableDenseThreeDimensionalModel::toDelimitedDataString(QString delimiter, size_t f0, size_t f1) const
Chris@838 488 {
Chris@838 489 QReadLocker locker(&m_lock);
Chris@838 490 QString s;
Chris@838 491 for (size_t i = 0; i < m_data.size(); ++i) {
Chris@838 492 size_t fr = m_startFrame + i * m_resolution;
Chris@838 493 if (fr >= f0 && fr < f1) {
Chris@838 494 QStringList list;
Chris@838 495 for (size_t j = 0; j < m_data.at(i).size(); ++j) {
Chris@838 496 list << QString("%1").arg(m_data.at(i).at(j));
Chris@838 497 }
Chris@838 498 s += list.join(delimiter) + "\n";
Chris@838 499 }
Chris@838 500 }
Chris@838 501 return s;
Chris@838 502 }
Chris@838 503
Chris@152 504 void
Chris@152 505 EditableDenseThreeDimensionalModel::toXml(QTextStream &out,
Chris@314 506 QString indent,
Chris@314 507 QString extraAttributes) const
Chris@152 508 {
Chris@536 509 QReadLocker locker(&m_lock);
Chris@534 510
Chris@152 511 // For historical reasons we read and write "resolution" as "windowSize"
Chris@152 512
Chris@690 513 SVDEBUG << "EditableDenseThreeDimensionalModel::toXml" << endl;
Chris@318 514
Chris@314 515 Model::toXml
Chris@314 516 (out, indent,
Chris@611 517 QString("type=\"dense\" dimensions=\"3\" windowSize=\"%1\" yBinCount=\"%2\" minimum=\"%3\" maximum=\"%4\" dataset=\"%5\" startFrame=\"%6\" %7")
Chris@152 518 .arg(m_resolution)
Chris@152 519 .arg(m_yBinCount)
Chris@152 520 .arg(m_minimum)
Chris@152 521 .arg(m_maximum)
Chris@152 522 .arg(getObjectExportId(&m_data))
Chris@611 523 .arg(m_startFrame)
Chris@152 524 .arg(extraAttributes));
Chris@152 525
Chris@152 526 out << indent;
Chris@152 527 out << QString("<dataset id=\"%1\" dimensions=\"3\" separator=\" \">\n")
Chris@152 528 .arg(getObjectExportId(&m_data));
Chris@152 529
Chris@152 530 for (size_t i = 0; i < m_binNames.size(); ++i) {
Chris@152 531 if (m_binNames[i] != "") {
Chris@152 532 out << indent + " ";
Chris@152 533 out << QString("<bin number=\"%1\" name=\"%2\"/>\n")
Chris@152 534 .arg(i).arg(m_binNames[i]);
Chris@152 535 }
Chris@152 536 }
Chris@152 537
Chris@152 538 for (size_t i = 0; i < m_data.size(); ++i) {
Chris@152 539 out << indent + " ";
Chris@152 540 out << QString("<row n=\"%1\">").arg(i);
Chris@533 541 for (size_t j = 0; j < m_data.at(i).size(); ++j) {
Chris@152 542 if (j > 0) out << " ";
Chris@533 543 out << m_data.at(i).at(j);
Chris@152 544 }
Chris@152 545 out << QString("</row>\n");
Chris@318 546 out.flush();
Chris@152 547 }
Chris@152 548
Chris@152 549 out << indent + "</dataset>\n";
Chris@152 550 }
Chris@152 551
Chris@152 552